diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5a5ed78..fade694 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,8 +1,11 @@ { "@@locale": "en", "about": "About", + "@about": {}, "accept": "Accept", + "@accept": {}, "active": "Active", + "@active": {}, "actor": "{count, plural, other{Actors} one{Actor}}", "@actor": { "description": "actor", @@ -14,14 +17,23 @@ } }, "addAsFavorite": "Add as favorite", + "@addAsFavorite": {}, "addToCollection": "Add to collection", + "@addToCollection": {}, "addToPlaylist": "Add to playlist", + "@addToPlaylist": {}, "advanced": "Advanced", + "@advanced": {}, "all": "All", + "@all": {}, "amoledBlack": "Amoled black", + "@amoledBlack": {}, "appLockAutoLogin": "Auto login", + "@appLockAutoLogin": {}, "appLockBiometrics": "Biometrics", + "@appLockBiometrics": {}, "appLockPasscode": "Passcode", + "@appLockPasscode": {}, "appLockTitle": "Set the log-in method for {userName}", "@appLockTitle": { "description": "Pop-up to pick a login method", @@ -32,15 +44,24 @@ } }, "ascending": "Ascending", + "@ascending": {}, "audio": "Audio", + "@audio": {}, "autoPlay": "Auto-play", + "@autoPlay": {}, "backgroundBlur": "Background blur", + "@backgroundBlur": {}, "backgroundOpacity": "Background opacity", - "biometricsFailedCheckAgain": "Biometrics failed check settings and try again", + "@backgroundOpacity": {}, + "biometricsFailedCheckAgain": "Biometrics failed. Check settings and try again.", + "@biometricsFailedCheckAgain": {}, "bold": "Bold", + "@bold": {}, "cancel": "Cancel", + "@cancel": {}, "change": "Change", - "chapter": "{count, plural, other{Chapters} one{Chapter}}", + "@change": {}, + "chapter": "{count, plural, other{Chapters} one{Chapter}}", "@chapter": { "description": "chapter", "placeholders": { @@ -51,16 +72,27 @@ } }, "clear": "Clear", + "@clear": {}, "clearAllSettings": "Clear all settings", + "@clearAllSettings": {}, "clearAllSettingsQuestion": "Clear all settings?", + "@clearAllSettingsQuestion": {}, "clearChanges": "Clear changes", + "@clearChanges": {}, "clearSelection": "Clear selection", + "@clearSelection": {}, "close": "Close", + "@close": {}, "code": "Code", + "@code": {}, "collectionFolder": "Collection folder", + "@collectionFolder": {}, "color": "Color", + "@color": {}, "combined": "Combined", + "@combined": {}, "communityRating": "Community Rating", + "@communityRating": {}, "continuePage": "Continue - page {page}", "@continuePage": { "description": "Continue - page 1", @@ -71,12 +103,19 @@ } }, "controls": "Controls", + "@controls": {}, "dashboard": "Dashboard", + "@dashboard": {}, "dashboardContinue": "Continue", + "@dashboardContinue": {}, "dashboardContinueListening": "Continue Listening", + "@dashboardContinueListening": {}, "dashboardContinueReading": "Continue Reading", + "@dashboardContinueReading": {}, "dashboardContinueWatching": "Continue Watching", + "@dashboardContinueWatching": {}, "dashboardNextUp": "Next-up", + "@dashboardNextUp": {}, "dashboardRecentlyAdded": "Recently added in {name}", "@dashboardRecentlyAdded": { "description": "Recently added on home screen", @@ -87,10 +126,15 @@ } }, "dateAdded": "Date added", + "@dateAdded": {}, "dateLastContentAdded": "Date last content added", + "@dateLastContentAdded": {}, "datePlayed": "Date played", + "@datePlayed": {}, "days": "Days", + "@days": {}, "delete": "Delete", + "@delete": {}, "deleteFileFromSystem": "Deleting this item {item} will delete it from both the file system and your media library.\nAre you sure you wish to continue?", "@deleteFileFromSystem": { "description": "Delete file from system", @@ -110,7 +154,8 @@ } }, "descending": "Descending", - "director": "{count, plural, other{Director} two{Directors}}", + "@descending": {}, + "director": "{count, plural, other{Director} two{Directors}}", "@director": { "description": "director", "placeholders": { @@ -120,19 +165,32 @@ } } }, - "disableFilters": "Disable filters", - "disabled": "Disabled", + "disableFilters": "Turn off filters", + "@disableFilters": {}, + "disabled": "Off", + "@disabled": {}, "discovered": "Discovered", + "@discovered": {}, "displayLanguage": "Display language", - "downloadsClearDesc": "Are you sure you want to remove all synced data?\nThis will clear all data for every synced user!", + "@displayLanguage": {}, + "downloadsClearDesc": "Remove all synced data, clearing\nall data for every synced user?", + "@downloadsClearDesc": {}, "downloadsClearTitle": "Clear synced data", + "@downloadsClearTitle": {}, "downloadsPath": "Path", + "@downloadsPath": {}, "downloadsSyncedData": "Synced data", + "@downloadsSyncedData": {}, "downloadsTitle": "Downloads", + "@downloadsTitle": {}, "dynamicText": "Dynamic", + "@dynamicText": {}, "editMetadata": "Edit metadata", + "@editMetadata": {}, "empty": "Empty", - "enabled": "Enabled", + "@empty": {}, + "enabled": "On", + "@enabled": {}, "endsAt": "ends at {date}", "@endsAt": { "description": "endsAt", @@ -154,10 +212,15 @@ } }, "error": "Error", - "failedToLoadImage": "Failed to load image", + "@error": {}, + "failedToLoadImage": "Could not load image", + "@failedToLoadImage": {}, "favorite": "Favorite", + "@favorite": {}, "favorites": "Favorites", - "fetchingLibrary": "Fetching library items", + "@favorites": {}, + "fetchingLibrary": "Fetching library items…", + "@fetchingLibrary": {}, "filter": "{count, plural, other{Filters} one{Filter}}", "@filter": { "description": "filter", @@ -169,9 +232,13 @@ } }, "folders": "Folders", + "@folders": {}, "fontColor": "Font color", + "@fontColor": {}, "fontSize": "Font size", + "@fontSize": {}, "forceRefresh": "Force refresh", + "@forceRefresh": {}, "genre": "{count, plural, other{Genres} one{Genre}}", "@genre": { "description": "genre", @@ -183,19 +250,35 @@ } }, "goTo": "Go To", + "@goTo": {}, "grid": "Grid", + "@grid": {}, "group": "Group", + "@group": {}, "groupBy": "Group by", + "@groupBy": {}, "heightOffset": "Height offset", + "@heightOffset": {}, "hide": "Hide", + "@hide": {}, "hideEmpty": "Hide empty", + "@hideEmpty": {}, "home": "Home", + "@home": {}, + "homeBannerBanner": "Banner", + "homeBannerCarousel": "Carousel", "identify": "Identify", + "@identify": {}, "immediately": "Immediately", - "incorrectPinTryAgain": "Incorrect pin try again", + "@immediately": {}, + "incorrectPinTryAgain": "Incorrect PIN. Try again.", + "@incorrectPinTryAgain": {}, "info": "Info", - "invalidUrl": "Invalid url", - "invalidUrlDesc": "Url needs to start with http(s)://", + "@info": {}, + "invalidUrl": "Invalid URL", + "@invalidUrl": {}, + "invalidUrlDesc": "URL needs to start with http(s)://", + "@invalidUrlDesc": {}, "itemCount": "Item count: {count}", "@itemCount": { "description": "Item count", @@ -205,7 +288,7 @@ } } }, - "label": "{count, plural, other{Labels} one{Label}}", + "label": "{count, plural, other{Labels} one{Label}}", "@label": { "description": "label", "placeholders": { @@ -225,16 +308,25 @@ } } }, - "libraryFetchNoItemsFound": "No items found, try different settings.", - "libraryPageSizeDesc": "Set the amount to load at a time. 0 disables paging", + "libraryFetchNoItemsFound": "No items found. Try different settings.", + "@libraryFetchNoItemsFound": {}, + "libraryPageSizeDesc": "Set the amount to load at a time. 0 turns off paging.", + "@libraryPageSizeDesc": {}, "libraryPageSizeTitle": "Library page size", + "@libraryPageSizeTitle": {}, "light": "Light", + "@light": {}, "list": "List", + "@list": {}, "lockscreen": "Lockscreen", + "@lockscreen": {}, "loggedIn": "Logged-in", - "login": "Login", - "logout": "Logout", - "logoutUserPopupContent": "This will log-out {userName} and delete te user from the app.\nYou will have to log back in to {serverName}.", + "@loggedIn": {}, + "login": "Log in", + "@login": {}, + "logout": "Log out", + "@logout": {}, + "logoutUserPopupContent": "This will log out {userName} and delete the user from the app.\nYou will have to log back in on {serverName}.", "@logoutUserPopupContent": { "description": "Pop-up for loging out the user description", "placeholders": { @@ -246,7 +338,7 @@ } } }, - "logoutUserPopupTitle": "Log-out user {userName}?", + "logoutUserPopupTitle": "Log out {userName}?", "@logoutUserPopupTitle": { "description": "Pop-up for loging out the user", "placeholders": { @@ -256,21 +348,37 @@ } }, "loop": "Loop", + "@loop": {}, "markAsUnwatched": "Mark as unwatched", + "@markAsUnwatched": {}, "markAsWatched": "Mark as watched", + "@markAsWatched": {}, "masonry": "Masonry", + "@masonry": {}, "mediaTypeBase": "Base Type", + "@mediaTypeBase": {}, "mediaTypeBook": "Book", + "@mediaTypeBook": {}, "mediaTypeBoxset": "Boxset", + "@mediaTypeBoxset": {}, "mediaTypeEpisode": "Episode", + "@mediaTypeEpisode": {}, "mediaTypeFolder": "Folder", + "@mediaTypeFolder": {}, "mediaTypeMovie": "Movie", + "@mediaTypeMovie": {}, "mediaTypePerson": "Person", + "@mediaTypePerson": {}, "mediaTypePhoto": "Photo", + "@mediaTypePhoto": {}, "mediaTypePhotoAlbum": "Photo Album", + "@mediaTypePhotoAlbum": {}, "mediaTypePlaylist": "Playlist", + "@mediaTypePlaylist": {}, "mediaTypeSeason": "Season", + "@mediaTypeSeason": {}, "mediaTypeSeries": "Series", + "@mediaTypeSeries": {}, "metaDataSavedFor": "Metadata saved for {item}", "@metaDataSavedFor": { "description": "metaDataSavedFor", @@ -281,8 +389,11 @@ } }, "metadataRefreshDefault": "Scan for new and updated files", + "@metadataRefreshDefault": {}, "metadataRefreshFull": "Replace all metadata", + "@metadataRefreshFull": {}, "metadataRefreshValidation": "Search for missing metadata", + "@metadataRefreshValidation": {}, "minutes": "{count, plural, other{Minutes} one{Minute} }", "@minutes": { "description": "minute", @@ -294,6 +405,7 @@ } }, "mode": "Mode", + "@mode": {}, "moreFrom": "More from {info}", "@moreFrom": { "description": "More from", @@ -304,32 +416,61 @@ } }, "moreOptions": "More options", + "@moreOptions": {}, "mouseDragSupport": "Drag using mouse", + "@mouseDragSupport": {}, "musicAlbum": "Album", + "@musicAlbum": {}, "name": "Name", + "@name": {}, + "nativeName": "English", + "@nativeName": {}, "navigation": "Navigation", + "@navigation": {}, "navigationDashboard": "Dashboard", + "@navigationDashboard": {}, "navigationFavorites": "Favorites", + "@navigationFavorites": {}, "navigationSync": "Synced", + "@navigationSync": {}, "never": "Never", + "@never": {}, "nextUp": "Next Up", + "@nextUp": {}, "noItemsSynced": "No items synced", + "@noItemsSynced": {}, "noItemsToShow": "No items to show", + "@noItemsToShow": {}, "noRating": "No rating", + "@noRating": {}, "noResults": "No results", + "@noResults": {}, "noServersFound": "No new servers found", + "@noServersFound": {}, "noSuggestionsFound": "No suggestions found", + "@noSuggestionsFound": {}, "none": "None", + "@none": {}, "normal": "Normal", + "@normal": {}, "notPartOfAlbum": "Not part of a album", + "@notPartOfAlbum": {}, "openParent": "Open parent", + "@openParent": {}, "openShow": "Open show", + "@openShow": {}, "openWebLink": "Open web link", + "@openWebLink": {}, "options": "Options", + "@options": {}, "other": "Other", + "@other": {}, "outlineColor": "Outline color", + "@outlineColor": {}, "outlineSize": "Outline size", + "@outlineSize": {}, "overview": "Overview", + "@overview": {}, "page": "Page {index}", "@page": { "description": "page", @@ -340,11 +481,17 @@ } }, "parentalRating": "Parental Rating", + "@parentalRating": {}, "password": "Password", + "@password": {}, "pathClearTitle": "Clear downloads path", + "@pathClearTitle": {}, "pathEditDesc": "This location is set for all users, any synced data will no longer be accessible.\nIt will remain on your storage.", + "@pathEditDesc": {}, "pathEditSelect": "Select downloads destination", + "@pathEditSelect": {}, "pathEditTitle": "Change location", + "@pathEditTitle": {}, "play": "Play {item}", "@play": { "description": "Play with", @@ -355,6 +502,7 @@ } }, "playCount": "Play count", + "@playCount": {}, "playFrom": "Play from {name}", "@playFrom": { "description": "playFrom", @@ -374,13 +522,21 @@ } }, "playLabel": "Play", + "@playLabel": {}, "playVideos": "Play videos", + "@playVideos": {}, "played": "Played", + "@played": {}, "quickConnectAction": "Enter quick connect code for", + "@quickConnectAction": {}, "quickConnectInputACode": "Input a code", + "@quickConnectInputACode": {}, "quickConnectTitle": "Quick-connect", + "@quickConnectTitle": {}, "quickConnectWrongCode": "Wrong code", + "@quickConnectWrongCode": {}, "random": "Random", + "@random": {}, "rating": "{count, plural, other{Ratings} one{Rating}}", "@rating": { "description": "rating", @@ -392,6 +548,7 @@ } }, "reWatch": "Rewatch", + "@reWatch": {}, "read": "Read {item}", "@read": { "description": "read", @@ -411,8 +568,11 @@ } }, "recursive": "Recursive", + "@recursive": {}, "refresh": "Refresh", + "@refresh": {}, "refreshMetadata": "Refresh metadata", + "@refreshMetadata": {}, "refreshPopup": "Refresh - {name}", "@refreshPopup": { "placeholders": { @@ -421,17 +581,28 @@ } } }, - "refreshPopupContentMetadata": "Metadata is refreshed based on settings and internet services that are enabled in the Dashboard.", + "refreshPopupContentMetadata": "Metadata is refreshed based on settings and Internet services turned on in the dashboard.", + "@refreshPopupContentMetadata": {}, "related": "Related", + "@related": {}, "releaseDate": "Release date", + "@releaseDate": {}, "removeAsFavorite": "Remove as favorite", - "removeFromCollection": "Remove to collection", - "removeFromPlaylist": "Remove to playlist", + "@removeAsFavorite": {}, + "removeFromCollection": "Remove from collection", + "@removeFromCollection": {}, + "removeFromPlaylist": "Remove from playlist", + "@removeFromPlaylist": {}, "replaceAllImages": "Replace all images", + "@replaceAllImages": {}, "replaceExistingImages": "Replace existing images", + "@replaceExistingImages": {}, "restart": "Restart", + "@restart": {}, "result": "Result", + "@result": {}, "resumable": "Resumable", + "@resumable": {}, "resume": "Resume {item}", "@resume": { "description": "resume", @@ -442,12 +613,19 @@ } }, "retrievePublicListOfUsers": "Retrieve public list of users", + "@retrievePublicListOfUsers": {}, "retry": "Retry", + "@retry": {}, "runTime": "Run time", + "@runTime": {}, "save": "Save", + "@save": {}, "saved": "Saved", + "@saved": {}, "scanBiometricHint": "Verify identity", + "@scanBiometricHint": {}, "scanLibrary": "Scan library", + "@scanLibrary": {}, "scanYourFingerprintToAuthenticate": "Scan your fingerprint to authenticate {user}", "@scanYourFingerprintToAuthenticate": { "placeholders": { @@ -456,7 +634,7 @@ } } }, - "scanningName": "Scanning - {name}", + "scanningName": "Scanning - {name}…", "@scanningName": { "placeholders": { "name": { @@ -465,7 +643,9 @@ } }, "scrollToTop": "Scroll to top", + "@scrollToTop": {}, "search": "Search", + "@search": {}, "season": "{count, plural, other{Seasons} one{Season} }", "@season": { "description": "season", @@ -487,9 +667,13 @@ } }, "selectAll": "Select all", + "@selectAll": {}, "selectTime": "Select time", + "@selectTime": {}, "selectViewType": "Select view type", + "@selectViewType": {}, "selected": "Selected", + "@selected": {}, "selectedWith": "Selected {info}", "@selectedWith": { "description": "selected", @@ -500,7 +684,9 @@ } }, "separate": "Separate", + "@separate": {}, "server": "Server", + "@server": {}, "set": "Set", "@set": { "description": "Use for setting a certain value", @@ -516,50 +702,95 @@ } }, "settingSecurityApplockTitle": "App lock", + "@settingSecurityApplockTitle": {}, "settings": "Settings", + "@settings": {}, "settingsBlurEpisodesDesc": "Blur all upcoming episodes", + "@settingsBlurEpisodesDesc": {}, "settingsBlurEpisodesTitle": "Blur next-up episodes", + "@settingsBlurEpisodesTitle": {}, "settingsBlurredPlaceholderDesc": "Show blurred background when loading posters", + "@settingsBlurredPlaceholderDesc": {}, "settingsBlurredPlaceholderTitle": "Blurred placeholder", + "@settingsBlurredPlaceholderTitle": {}, "settingsClientDesc": "General, Time-out, Layout, Theme", + "@settingsClientDesc": {}, "settingsClientTitle": "Fladder", + "@settingsClientTitle": {}, "settingsContinue": "Continue", + "@settingsContinue": {}, "settingsEnableOsMediaControls": "Enable OS media controls", - "settingsHomeCarouselDesc": "Shows a carousel on the dashboard screen", - "settingsHomeCarouselTitle": "Dashboard carousel", + "@settingsEnableOsMediaControls": {}, + "settingsHomeBannerDescription": "Switch between a banner or scrollable carousel", + "settingsHomeBannerTitle": "Home banner", + "settingsHomeCarouselDesc": "Shows a banner on the dashboard screen", + "settingsHomeCarouselTitle": "Dashboard banner", "settingsHomeNextUpDesc": "Type of posters shown in the dashboard screen", + "@settingsHomeNextUpDesc": {}, "settingsHomeNextUpTitle": "Next-up posters", + "@settingsHomeNextUpTitle": {}, "settingsNextUpCutoffDays": "Next-up cutoff days", + "@settingsNextUpCutoffDays": {}, "settingsPlayerCustomSubtitlesDesc": "Customize Size, Color, Position, Outline", + "@settingsPlayerCustomSubtitlesDesc": {}, "settingsPlayerCustomSubtitlesTitle": "Customize subtitles", + "@settingsPlayerCustomSubtitlesTitle": {}, "settingsPlayerDesc": "Aspect-ratio, Advanced", - "settingsPlayerMobileWarning": "Enabling Hardware acceleration and native libass subtitles on Android might cause some subtitles to not render.", + "@settingsPlayerDesc": {}, + "settingsPlayerMobileWarning": "Turning on hardware acceleration and native libass subtitles on Android might cause some subtitles to not render.", + "@settingsPlayerMobileWarning": {}, "settingsPlayerNativeLibassAccelDesc": "Use video player libass subtitle renderer", + "@settingsPlayerNativeLibassAccelDesc": {}, "settingsPlayerNativeLibassAccelTitle": "Native libass subtitles", + "@settingsPlayerNativeLibassAccelTitle": {}, "settingsPlayerTitle": "Player", - "settingsPlayerVideoHWAccelDesc": "Use the gpu to render video (recommended)", + "@settingsPlayerTitle": {}, + "settingsPlayerVideoHWAccelDesc": "Use the GPU to render video (recommended)", + "@settingsPlayerVideoHWAccelDesc": {}, "settingsPlayerVideoHWAccelTitle": "Hardware acceleration", + "@settingsPlayerVideoHWAccelTitle": {}, "settingsPosterPinch": "Pinch-zoom to scale posters", + "@settingsPosterPinch": {}, "settingsPosterSize": "Poster size", + "@settingsPosterSize": {}, "settingsPosterSlider": "Show scale slider", + "@settingsPosterSlider": {}, "settingsProfileDesc": "Lockscreen", + "@settingsProfileDesc": {}, "settingsProfileTitle": "Profile", + "@settingsProfileTitle": {}, "settingsQuickConnectTitle": "Quick connect", + "@settingsQuickConnectTitle": {}, "settingsSecurity": "Security", + "@settingsSecurity": {}, "settingsShowScaleSlider": "Show poster size slide", + "@settingsShowScaleSlider": {}, "settingsVisual": "Visual", + "@settingsVisual": {}, "shadow": "Shadow", + "@shadow": {}, "showAlbum": "Show album", + "@showAlbum": {}, "showDetails": "Show details", + "@showDetails": {}, "showEmpty": "Show empty", + "@showEmpty": {}, "shuffleGallery": "Shuffle gallery", + "@shuffleGallery": {}, "shuffleVideos": "Shuffle videos", + "@shuffleVideos": {}, "somethingWentWrong": "Something went wrong", - "somethingWentWrongPasswordCheck": "Something went wrong, check your password", + "@somethingWentWrong": {}, + "somethingWentWrongPasswordCheck": "Something went wrong. Check your password.", + "@somethingWentWrongPasswordCheck": {}, "sortBy": "Sort by", + "@sortBy": {}, "sortName": "Name", + "@sortName": {}, "sortOrder": "Sort order", + "@sortOrder": {}, "start": "Start", + "@start": {}, "studio": "{count, plural, other{Studios} one{Studio}}", "@studio": { "description": "studio", @@ -571,10 +802,15 @@ } }, "subtitleConfigurator": "Subtitle configurator", + "@subtitleConfigurator": {}, "subtitleConfiguratorPlaceHolder": "This is placeholder text, \n nothing to see here.", + "@subtitleConfiguratorPlaceHolder": {}, "subtitles": "Subtitles", + "@subtitles": {}, "switchUser": "Switch user", + "@switchUser": {}, "sync": "Sync", + "@sync": {}, "syncDeleteItemDesc": "Delete all synced data for?\n{item}", "@syncDeleteItemDesc": { "description": "Sync delete item pop-up window", @@ -585,12 +821,19 @@ } }, "syncDeleteItemTitle": "Delete synced item", + "@syncDeleteItemTitle": {}, "syncDeletePopupPermanent": "This action is permanent and will remove all localy synced files", + "@syncDeletePopupPermanent": {}, "syncDetails": "Sync details", + "@syncDetails": {}, "syncOpenParent": "Open parent", + "@syncOpenParent": {}, "syncRemoveDataDesc": "Delete synced video data? This is permanent and you will need to re-sync the files", + "@syncRemoveDataDesc": {}, "syncRemoveDataTitle": "Remove synced data?", + "@syncRemoveDataTitle": {}, "syncedItems": "Synced items", + "@syncedItems": {}, "tag": "{count, plural, one{Tag} other{Tags}}", "@tag": { "description": "tag", @@ -602,10 +845,15 @@ } }, "theme": "Theme", + "@theme": {}, "themeColor": "Theme color", + "@themeColor": {}, "themeModeDark": "Dark", + "@themeModeDark": {}, "themeModeLight": "Light", + "@themeModeLight": {}, "themeModeSystem": "System", + "@themeModeSystem": {}, "timeAndAnnotation": "{minutes} and {seconds}", "@timeAndAnnotation": { "description": "timeAndAnnotation", @@ -619,6 +867,7 @@ } }, "timeOut": "Time-out", + "@timeOut": {}, "totalSize": "Total size: {size}", "@totalSize": { "placeholders": { @@ -638,24 +887,43 @@ } }, "unPlayed": "Unplayed", + "@unPlayed": {}, "unableToConnectHost": "Unable to connect to host", - "unableToReverseAction": "This action can not be reversed, it will remove all settings.", + "@unableToConnectHost": {}, + "unableToReverseAction": "This action can not be reversed. It will remove all settings.", + "@unableToReverseAction": {}, "unknown": "Unknown", + "@unknown": {}, "useDefaults": "Use defaults", + "@useDefaults": {}, "userName": "Username", + "@userName": {}, "video": "Video", + "@video": {}, "videoScaling": "Video scaling", + "@videoScaling": {}, "videoScalingContain": "Contain", + "@videoScalingContain": {}, "videoScalingCover": "Cover", + "@videoScalingCover": {}, "videoScalingFill": "Fill", + "@videoScalingFill": {}, "videoScalingFillScreenDesc": "Fill the navigation and statusbar", + "@videoScalingFillScreenDesc": {}, "videoScalingFillScreenNotif": "Fill-screen overwrites video fit, in horizontal rotation", + "@videoScalingFillScreenNotif": {}, "videoScalingFillScreenTitle": "Fill screen", + "@videoScalingFillScreenTitle": {}, "videoScalingFitHeight": "Fit Height", + "@videoScalingFitHeight": {}, "videoScalingFitWidth": "Fit Width", + "@videoScalingFitWidth": {}, "videoScalingScaleDown": "ScaleDown", + "@videoScalingScaleDown": {}, "viewPhotos": "View photos", + "@viewPhotos": {}, "watchOn": "Watch on", + "@watchOn": {}, "writer": "{count, plural, other{Writer} two{Writers}}", "@writer": { "description": "writer", diff --git a/lib/l10n/l10n_errors.txt b/lib/l10n/l10n_errors.txt index 9e26dfe..4e7bc2d 100644 --- a/lib/l10n/l10n_errors.txt +++ b/lib/l10n/l10n_errors.txt @@ -1 +1,41 @@ -{} \ No newline at end of file +{ + "es": [ + "nativeName", + "settingsHomeBannerTitle", + "settingsHomeBannerDescription", + "homeBannerBanner", + "homeBannerCarousel" + ], + + "fr": [ + "nativeName", + "settingsHomeBannerTitle", + "settingsHomeBannerDescription", + "homeBannerBanner", + "homeBannerCarousel" + ], + + "ja": [ + "nativeName", + "settingsHomeBannerTitle", + "settingsHomeBannerDescription", + "homeBannerBanner", + "homeBannerCarousel" + ], + + "nl": [ + "nativeName", + "settingsHomeBannerTitle", + "settingsHomeBannerDescription", + "homeBannerBanner", + "homeBannerCarousel" + ], + + "zh": [ + "nativeName", + "settingsHomeBannerTitle", + "settingsHomeBannerDescription", + "homeBannerBanner", + "homeBannerCarousel" + ] +} diff --git a/lib/models/settings/client_settings_model.dart b/lib/models/settings/client_settings_model.dart index 517dba1..89b5242 100644 --- a/lib/models/settings/client_settings_model.dart +++ b/lib/models/settings/client_settings_model.dart @@ -4,10 +4,11 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:fladder/util/custom_color_themes.dart'; - import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:fladder/util/custom_color_themes.dart'; +import 'package:fladder/util/localization_helper.dart'; + part 'client_settings_model.freezed.dart'; part 'client_settings_model.g.dart'; @@ -22,6 +23,7 @@ class ClientSettingsModel with _$ClientSettingsModel { Duration? nextUpDateCutoff, @Default(ThemeMode.system) ThemeMode themeMode, ColorThemes? themeColor, + @Default(HomeBanner.carousel) HomeBanner homeBanner, @Default(false) bool amoledBlack, @Default(false) bool blurPlaceHolders, @Default(false) bool blurUpcomingEpisodes, @@ -71,6 +73,18 @@ class LocaleConvert implements JsonConverter { } } +enum HomeBanner { + carousel, + banner; + + const HomeBanner(); + + String label(BuildContext context) => switch (this) { + HomeBanner.carousel => context.localized.homeBannerCarousel, + HomeBanner.banner => context.localized.homeBannerBanner, + }; +} + class Vector2 { final double x; final double y; diff --git a/lib/models/settings/client_settings_model.freezed.dart b/lib/models/settings/client_settings_model.freezed.dart index e7b3a9d..4d52c01 100644 --- a/lib/models/settings/client_settings_model.freezed.dart +++ b/lib/models/settings/client_settings_model.freezed.dart @@ -27,6 +27,7 @@ mixin _$ClientSettingsModel { Duration? get nextUpDateCutoff => throw _privateConstructorUsedError; ThemeMode get themeMode => throw _privateConstructorUsedError; ColorThemes? get themeColor => throw _privateConstructorUsedError; + HomeBanner get homeBanner => throw _privateConstructorUsedError; bool get amoledBlack => throw _privateConstructorUsedError; bool get blurPlaceHolders => throw _privateConstructorUsedError; bool get blurUpcomingEpisodes => throw _privateConstructorUsedError; @@ -38,8 +39,12 @@ mixin _$ClientSettingsModel { bool get mouseDragSupport => throw _privateConstructorUsedError; int? get libraryPageSize => throw _privateConstructorUsedError; + /// Serializes this ClientSettingsModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of ClientSettingsModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $ClientSettingsModelCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -58,6 +63,7 @@ abstract class $ClientSettingsModelCopyWith<$Res> { Duration? nextUpDateCutoff, ThemeMode themeMode, ColorThemes? themeColor, + HomeBanner homeBanner, bool amoledBlack, bool blurPlaceHolders, bool blurUpcomingEpisodes, @@ -79,6 +85,8 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel> // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of ClientSettingsModel + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -89,6 +97,7 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel> Object? nextUpDateCutoff = freezed, Object? themeMode = null, Object? themeColor = freezed, + Object? homeBanner = null, Object? amoledBlack = null, Object? blurPlaceHolders = null, Object? blurUpcomingEpisodes = null, @@ -128,6 +137,10 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel> ? _value.themeColor : themeColor // ignore: cast_nullable_to_non_nullable as ColorThemes?, + homeBanner: null == homeBanner + ? _value.homeBanner + : homeBanner // ignore: cast_nullable_to_non_nullable + as HomeBanner, amoledBlack: null == amoledBlack ? _value.amoledBlack : amoledBlack // ignore: cast_nullable_to_non_nullable @@ -184,6 +197,7 @@ abstract class _$$ClientSettingsModelImplCopyWith<$Res> Duration? nextUpDateCutoff, ThemeMode themeMode, ColorThemes? themeColor, + HomeBanner homeBanner, bool amoledBlack, bool blurPlaceHolders, bool blurUpcomingEpisodes, @@ -203,6 +217,8 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res> $Res Function(_$ClientSettingsModelImpl) _then) : super(_value, _then); + /// Create a copy of ClientSettingsModel + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -213,6 +229,7 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res> Object? nextUpDateCutoff = freezed, Object? themeMode = null, Object? themeColor = freezed, + Object? homeBanner = null, Object? amoledBlack = null, Object? blurPlaceHolders = null, Object? blurUpcomingEpisodes = null, @@ -252,6 +269,10 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res> ? _value.themeColor : themeColor // ignore: cast_nullable_to_non_nullable as ColorThemes?, + homeBanner: null == homeBanner + ? _value.homeBanner + : homeBanner // ignore: cast_nullable_to_non_nullable + as HomeBanner, amoledBlack: null == amoledBlack ? _value.amoledBlack : amoledBlack // ignore: cast_nullable_to_non_nullable @@ -304,6 +325,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel this.nextUpDateCutoff, this.themeMode = ThemeMode.system, this.themeColor, + this.homeBanner = HomeBanner.carousel, this.amoledBlack = false, this.blurPlaceHolders = false, this.blurUpcomingEpisodes = false, @@ -338,6 +360,9 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel final ColorThemes? themeColor; @override @JsonKey() + final HomeBanner homeBanner; + @override + @JsonKey() final bool amoledBlack; @override @JsonKey() @@ -365,7 +390,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, libraryPageSize: $libraryPageSize)'; + return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, homeBanner: $homeBanner, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, libraryPageSize: $libraryPageSize)'; } @override @@ -380,6 +405,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel ..add(DiagnosticsProperty('nextUpDateCutoff', nextUpDateCutoff)) ..add(DiagnosticsProperty('themeMode', themeMode)) ..add(DiagnosticsProperty('themeColor', themeColor)) + ..add(DiagnosticsProperty('homeBanner', homeBanner)) ..add(DiagnosticsProperty('amoledBlack', amoledBlack)) ..add(DiagnosticsProperty('blurPlaceHolders', blurPlaceHolders)) ..add(DiagnosticsProperty('blurUpcomingEpisodes', blurUpcomingEpisodes)) @@ -408,6 +434,8 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel other.themeMode == themeMode) && (identical(other.themeColor, themeColor) || other.themeColor == themeColor) && + (identical(other.homeBanner, homeBanner) || + other.homeBanner == homeBanner) && (identical(other.amoledBlack, amoledBlack) || other.amoledBlack == amoledBlack) && (identical(other.blurPlaceHolders, blurPlaceHolders) || @@ -428,7 +456,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel other.libraryPageSize == libraryPageSize)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash( runtimeType, @@ -439,6 +467,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel nextUpDateCutoff, themeMode, themeColor, + homeBanner, amoledBlack, blurPlaceHolders, blurUpcomingEpisodes, @@ -449,7 +478,9 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel mouseDragSupport, libraryPageSize); - @JsonKey(ignore: true) + /// Create a copy of ClientSettingsModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$ClientSettingsModelImplCopyWith<_$ClientSettingsModelImpl> get copyWith => @@ -473,6 +504,7 @@ abstract class _ClientSettingsModel extends ClientSettingsModel { final Duration? nextUpDateCutoff, final ThemeMode themeMode, final ColorThemes? themeColor, + final HomeBanner homeBanner, final bool amoledBlack, final bool blurPlaceHolders, final bool blurUpcomingEpisodes, @@ -502,6 +534,8 @@ abstract class _ClientSettingsModel extends ClientSettingsModel { @override ColorThemes? get themeColor; @override + HomeBanner get homeBanner; + @override bool get amoledBlack; @override bool get blurPlaceHolders; @@ -520,8 +554,11 @@ abstract class _ClientSettingsModel extends ClientSettingsModel { bool get mouseDragSupport; @override int? get libraryPageSize; + + /// Create a copy of ClientSettingsModel + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$ClientSettingsModelImplCopyWith<_$ClientSettingsModelImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/models/settings/client_settings_model.g.dart b/lib/models/settings/client_settings_model.g.dart index 4b71fd5..5b8a0d2 100644 --- a/lib/models/settings/client_settings_model.g.dart +++ b/lib/models/settings/client_settings_model.g.dart @@ -25,6 +25,9 @@ _$ClientSettingsModelImpl _$$ClientSettingsModelImplFromJson( themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? ThemeMode.system, themeColor: $enumDecodeNullable(_$ColorThemesEnumMap, json['themeColor']), + homeBanner: + $enumDecodeNullable(_$HomeBannerEnumMap, json['homeBanner']) ?? + HomeBanner.carousel, amoledBlack: json['amoledBlack'] as bool? ?? false, blurPlaceHolders: json['blurPlaceHolders'] as bool? ?? false, blurUpcomingEpisodes: json['blurUpcomingEpisodes'] as bool? ?? false, @@ -47,6 +50,7 @@ Map _$$ClientSettingsModelImplToJson( 'nextUpDateCutoff': instance.nextUpDateCutoff?.inMicroseconds, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'themeColor': _$ColorThemesEnumMap[instance.themeColor], + 'homeBanner': _$HomeBannerEnumMap[instance.homeBanner]!, 'amoledBlack': instance.amoledBlack, 'blurPlaceHolders': instance.blurPlaceHolders, 'blurUpcomingEpisodes': instance.blurUpcomingEpisodes, @@ -81,3 +85,8 @@ const _$ColorThemesEnumMap = { ColorThemes.deepPurple: 'deepPurple', ColorThemes.blueGrey: 'blueGrey', }; + +const _$HomeBannerEnumMap = { + HomeBanner.carousel: 'carousel', + HomeBanner.banner: 'banner', +}; diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 5e738d8..f190bbc 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -16,7 +16,7 @@ import 'package:fladder/providers/settings/home_settings_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/views_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; -import 'package:fladder/screens/shared/media/carousel_banner.dart'; +import 'package:fladder/screens/dashboard/top_posters_row.dart'; import 'package:fladder/screens/shared/media/poster_row.dart'; import 'package:fladder/screens/shared/nested_scaffold.dart'; import 'package:fladder/screens/shared/nested_sliver_appbar.dart'; @@ -104,20 +104,7 @@ class _DashboardScreenState extends ConsumerState { SliverToBoxAdapter( child: Transform.translate( offset: Offset(0, AdaptiveLayout.layoutOf(context) == LayoutState.phone ? -14 : 0), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: AdaptiveLayout.of(context).isDesktop ? 350 : 275, - maxHeight: (MediaQuery.sizeOf(context).height * 0.25).clamp(400, double.infinity)), - child: AspectRatio( - aspectRatio: 1.6, - child: SizedBox( - width: MediaQuery.of(context).size.width, - child: CarouselBanner( - items: homeCarouselItems, - ), - ), - ), - ), + child: TopPostersRow(posters: homeCarouselItems), ), ), } else if (AdaptiveLayout.of(context).isDesktop) diff --git a/lib/screens/dashboard/top_posters_row.dart b/lib/screens/dashboard/top_posters_row.dart new file mode 100644 index 0000000..0147543 --- /dev/null +++ b/lib/screens/dashboard/top_posters_row.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/models/settings/client_settings_model.dart'; +import 'package:fladder/providers/settings/client_settings_provider.dart'; +import 'package:fladder/screens/shared/media/carousel_banner.dart'; +import 'package:fladder/screens/shared/media/media_banner.dart'; + +class TopPostersRow extends ConsumerWidget { + final List posters; + const TopPostersRow({required this.posters, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bannerType = ref.watch(clientSettingsProvider.select((value) => value.homeBanner)); + final maxHeight = (MediaQuery.sizeOf(context).shortestSide * 0.6).clamp(125.0, 350.0); + return switch (bannerType) { + HomeBanner.carousel => Column( + mainAxisSize: MainAxisSize.min, + children: [ + CarouselBanner( + items: posters, + maxHeight: maxHeight, + ), + const SizedBox(height: 8) + ], + ), + HomeBanner.banner => MediaBanner( + items: posters, + maxHeight: maxHeight, + ) + }; + } +} diff --git a/lib/screens/settings/client_settings_page.dart b/lib/screens/settings/client_settings_page.dart index 3560601..f962fd8 100644 --- a/lib/screens/settings/client_settings_page.dart +++ b/lib/screens/settings/client_settings_page.dart @@ -7,6 +7,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fladder/models/settings/client_settings_model.dart'; import 'package:fladder/models/settings/home_settings_model.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/home_settings_provider.dart'; @@ -186,6 +187,28 @@ class _ClientSettingsPageState extends ConsumerState { .toList(), ), ), + SettingsListTile( + label: Text(context.localized.settingsHomeBannerTitle), + subLabel: Text(context.localized.settingsHomeBannerDescription), + trailing: EnumBox( + current: ref.watch( + clientSettingsProvider.select( + (value) => value.homeBanner.label(context), + ), + ), + itemBuilder: (context) => HomeBanner.values + .map( + (entry) => PopupMenuItem( + value: entry, + child: Text(entry.label(context)), + onTap: () => ref + .read(clientSettingsProvider.notifier) + .update((context) => context.copyWith(homeBanner: entry)), + ), + ) + .toList(), + ), + ), SettingsListTile( label: Text(context.localized.settingsHomeNextUpTitle), subLabel: Text(context.localized.settingsHomeNextUpDesc), diff --git a/lib/screens/settings/settings_scaffold.dart b/lib/screens/settings/settings_scaffold.dart index 62d2316..bdfd8a1 100644 --- a/lib/screens/settings/settings_scaffold.dart +++ b/lib/screens/settings/settings_scaffold.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; @@ -45,7 +44,7 @@ class SettingsScaffold extends ConsumerWidget { leading: context.router.backButton(), flexibleSpace: FlexibleSpaceBar( titlePadding: const EdgeInsets.symmetric(horizontal: 16) - .add(EdgeInsets.only(left: padding.left, right: padding.right)), + .add(EdgeInsets.only(left: padding.left, right: padding.right, bottom: 4)), title: Row( children: [ Text(label, style: Theme.of(context).textTheme.headlineLarge), @@ -75,8 +74,7 @@ class SettingsScaffold extends ConsumerWidget { ), ), SliverPadding( - padding: MediaQuery.paddingOf(context) - .copyWith(top: AdaptiveLayout.of(context).isDesktop || kIsWeb ? 0 : null), + padding: MediaQuery.paddingOf(context).copyWith(top: AdaptiveLayout.of(context).isDesktop ? 0 : 8), sliver: SliverList( delegate: SliverChildListDelegate(items), ), diff --git a/lib/screens/shared/media/carousel_banner.dart b/lib/screens/shared/media/carousel_banner.dart index 6609289..41876e0 100644 --- a/lib/screens/shared/media/carousel_banner.dart +++ b/lib/screens/shared/media/carousel_banner.dart @@ -1,26 +1,25 @@ -import 'package:async/async.dart'; +import 'package:flutter/material.dart'; + import 'package:collection/collection.dart'; -import 'package:ficonsax/ficonsax.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import 'package:fladder/models/item_base_model.dart'; -import 'package:fladder/models/items/movie_model.dart'; -import 'package:fladder/screens/shared/media/components/media_play_button.dart'; import 'package:fladder/util/adaptive_layout.dart'; import 'package:fladder/util/fladder_image.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; -import 'package:fladder/util/item_base_model/play_item_helpers.dart'; import 'package:fladder/util/list_padding.dart'; -import 'package:fladder/util/themes_data.dart'; +import 'package:fladder/widgets/shared/fladder_carousel.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; class CarouselBanner extends ConsumerStatefulWidget { final PageController? controller; final List items; + final double maxHeight; const CarouselBanner({ this.controller, required this.items, + this.maxHeight = 250, super.key, }); @@ -29,350 +28,120 @@ class CarouselBanner extends ConsumerStatefulWidget { } class _CarouselBannerState extends ConsumerState { - bool showControls = false; - bool interacting = false; - int currentPage = 0; - double dragOffset = 0; - double dragIntensity = 1; - double slidePosition = 1; - - late final RestartableTimer timer = RestartableTimer(const Duration(seconds: 8), () => nextSlide()); - - @override - void initState() { - super.initState(); - timer.reset(); - } - - @override - void dispose() { - timer.cancel(); - super.dispose(); - } - - void nextSlide() { - if (!interacting) { - setState(() { - if (currentPage == widget.items.length - 1) { - currentPage = 0; - } else { - currentPage++; - } - }); - } - timer.reset(); - } - - void previousSlide() { - if (!interacting) { - setState(() { - if (currentPage == 0) { - currentPage = widget.items.length - 1; - } else { - currentPage--; - } - }); - } - timer.reset(); - } - @override Widget build(BuildContext context) { - final overlayColor = ThemesData.of(context).dark.colorScheme.onSecondary; - final shadows = [ - BoxShadow(blurRadius: 12, spreadRadius: 8, color: overlayColor), - ]; - final currentItem = widget.items[currentPage.clamp(0, widget.items.length - 1)]; - final actions = currentItem.generateActions(context, ref); - - final double dragOpacity = (1 - dragOffset.abs()).clamp(0, 1); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Card( - elevation: 16, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - surfaceTintColor: overlayColor, - color: overlayColor, - child: GestureDetector( - onTap: () => currentItem.navigateTo(context), - onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch - ? () async { - interacting = true; - await showBottomSheetPill( - context: context, - content: (context, scrollController) => ListView( - controller: scrollController, - shrinkWrap: true, - children: actions.listTileItems(context, useIcons: true), - ), - ); - interacting = false; - timer.reset(); - } - : null, - child: MouseRegion( - onEnter: (event) => setState(() => showControls = true), - onHover: (event) => timer.reset(), - onExit: (event) => setState(() => showControls = false), - child: Stack( - fit: StackFit.expand, - children: [ - Dismissible( - key: const Key("Dismissable"), - direction: DismissDirection.horizontal, - onUpdate: (details) { - setState(() { - dragOffset = details.progress * 4; - }); - }, - confirmDismiss: (direction) async { - if (direction == DismissDirection.startToEnd) { - previousSlide(); - } else { - nextSlide(); - } - return false; - }, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 125), - opacity: dragOpacity.abs(), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 125), - child: Container( - key: Key(currentItem.id), - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - ), - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: Colors.white.withOpacity(0.10), strokeAlign: BorderSide.strokeAlignInside), - gradient: LinearGradient( - begin: Alignment.bottomLeft, - end: Alignment.topCenter, - colors: [ - overlayColor.withOpacity(1), - overlayColor.withOpacity(0.75), - overlayColor.withOpacity(0.45), - overlayColor.withOpacity(0.15), - overlayColor.withOpacity(0), - overlayColor.withOpacity(0), - overlayColor.withOpacity(0.1), - ], - ), - ), - child: SizedBox( - width: double.infinity, - height: double.infinity, - child: Padding( - padding: const EdgeInsets.all(1), - child: FladderImage( - fit: BoxFit.cover, - image: currentItem.bannerImage, - ), - ), - ), - ), - ), + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: widget.maxHeight), + child: LayoutBuilder( + builder: (context, constraints) { + final maxExtent = (constraints.maxHeight * 2.1).clamp(250.0, MediaQuery.sizeOf(context).shortestSide * 0.75); + final border = BorderRadius.circular(18); + return FladderCarousel( + shape: RoundedRectangleBorder(borderRadius: border), + onTap: (index) => widget.items[index].navigateTo(context), + onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer + ? null + : (index) { + final poster = widget.items[index]; + showBottomSheetPill( + context: context, + item: poster, + content: (scrollContext, scrollController) => ListView( + shrinkWrap: true, + controller: scrollController, + children: poster.generateActions(context, ref).listTileItems(scrollContext, useIcons: true), ), - ), - Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: IgnorePointer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - currentItem.title, - maxLines: 3, - style: Theme.of(context).textTheme.displaySmall?.copyWith( - shadows: shadows, - color: Colors.white, - ), - ), - ), - if (currentItem.label(context) != null && currentItem is! MovieModel) - Flexible( - child: Text( - currentItem.label(context)!, - maxLines: 3, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - shadows: shadows, - color: Colors.white.withOpacity(0.75), - ), - ), - ), - if (currentItem.overview.summary.isNotEmpty && - AdaptiveLayout.layoutOf(context) != LayoutState.phone) - Flexible( - child: Text( - currentItem.overview.summary, - maxLines: 3, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - shadows: shadows, - color: Colors.white.withOpacity(0.75), - ), - ), - ), - ].addInBetween(const SizedBox(height: 6)), - ), + ); + }, + onSecondaryTap: AdaptiveLayout.of(context).inputDevice == InputDevice.touch + ? null + : (details) async { + Offset localPosition = details.$2.globalPosition; + RelativeRect position = RelativeRect.fromLTRB( + localPosition.dx - 320, localPosition.dy, localPosition.dx, localPosition.dy); + final poster = widget.items[details.$1]; + + await showMenu( + context: context, + position: position, + items: poster.generateActions(context, ref).popupMenuItems(useIcons: true), + ); + }, + itemExtent: maxExtent, + children: [ + ...widget.items.mapIndexed( + (index, e) => LayoutBuilder(builder: (context, constraints) { + final opacity = (constraints.maxWidth / maxExtent); + return Stack( + clipBehavior: Clip.none, + children: [ + FladderImage(image: e.bannerImage), + AnimatedOpacity( + duration: const Duration(milliseconds: 250), + opacity: opacity.clamp(0, 1), + child: Stack( + children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomLeft, + end: Alignment.topCenter, + colors: [ + Theme.of(context).colorScheme.primaryContainer.withOpacity(0.75), + Colors.transparent, + ], ), ), - Wrap( - runSpacing: 6, - spacing: 6, - children: [ - if (currentItem.playAble) - MediaPlayButton( - item: currentItem, - onPressed: () async { - await currentItem.play( - context, - ref, - ); - }, - ), - ], - ), - ].addInBetween(const SizedBox(height: 16)), + ), ), - ), + ], ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: AnimatedOpacity( - opacity: showControls ? 1 : 0, - duration: const Duration(milliseconds: 250), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton.filledTonal( - onPressed: () => nextSlide(), - icon: const Icon(IconsaxOutline.arrow_right_3), - ) - ], - ), - ), - ), - ], - ), - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) + ), Align( - alignment: Alignment.bottomRight, + alignment: Alignment.bottomLeft, child: Padding( - padding: const EdgeInsets.all(16), - child: Card( - child: PopupMenuButton( - onOpened: () => interacting = true, - onCanceled: () { - interacting = false; - timer.reset(); - }, - itemBuilder: (context) => actions.popupMenuItems(useIcons: true), - ), + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.title, + maxLines: 2, + softWrap: e.title.length > 25, + textWidthBasis: TextWidthBasis.parent, + overflow: TextOverflow.fade, + style: Theme.of(context).textTheme.headlineMedium?.copyWith(color: Colors.white), + ), + if (e.label(context) != null) + Text( + e.label(context)!, + maxLines: 2, + softWrap: false, + overflow: TextOverflow.fade, + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white), + ), + ].addInBetween(const SizedBox(height: 4)), ), ), ), - ], - ), - ), - ), - ), - ), - GestureDetector( - onHorizontalDragUpdate: (details) { - final delta = (details.primaryDelta ?? 0) / 20; - slidePosition += delta; - if (slidePosition > 1) { - nextSlide(); - slidePosition = 0; - } else if (slidePosition < -1) { - previousSlide(); - slidePosition = 0; - } - }, - onHorizontalDragStart: (details) { - slidePosition = 0; - }, - child: Container( - color: Colors.black.withOpacity(0), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: widget.items.mapIndexed((index, e) { - return Tooltip( - message: '${e.name}\n${e.detailedName}', - child: Card( - elevation: 0, - color: Colors.transparent, - child: InkWell( - onTapUp: currentPage == index - ? null - : (details) { - animateToTarget(index); - timer.reset(); - }, - child: Container( - alignment: Alignment.center, - color: Colors.red.withOpacity(0), - width: 28, - height: 28, - child: AnimatedContainer( - duration: const Duration(milliseconds: 125), - width: currentItem == e ? 22 : 6, - height: currentItem == e ? 10 : 6, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: currentItem == e - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.primary.withOpacity(0.25), + Container( + decoration: BoxDecoration( + border: Border.all( + color: Colors.white.withOpacity(0.1), + width: 1.0, ), - ), - ), + borderRadius: border), ), - ), + ], ); - }).toList(), - ), - ), - ), - ) - ], + }), + ) + ], + ); + }, + ), ); } - - void animateToTarget(int nextIndex) { - int step = currentPage < nextIndex ? 1 : -1; - void updateItem(int item) { - Future.delayed(Duration(milliseconds: 64 ~/ ((currentPage - nextIndex).abs() / 3)), () { - setState(() { - currentPage = item; - }); - - if (currentPage != nextIndex) { - updateItem(item + step); - } - }); - timer.reset(); - } - - updateItem(currentPage + step); - } } diff --git a/lib/screens/shared/media/media_banner.dart b/lib/screens/shared/media/media_banner.dart new file mode 100644 index 0000000..62c80dc --- /dev/null +++ b/lib/screens/shared/media/media_banner.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; + +import 'package:async/async.dart'; +import 'package:collection/collection.dart'; +import 'package:ficonsax/ficonsax.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/models/items/movie_model.dart'; +import 'package:fladder/util/adaptive_layout.dart'; +import 'package:fladder/util/fladder_image.dart'; +import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; +import 'package:fladder/util/list_padding.dart'; +import 'package:fladder/util/themes_data.dart'; +import 'package:fladder/widgets/shared/fladder_carousel.dart'; +import 'package:fladder/widgets/shared/item_actions.dart'; +import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; + +class MediaBanner extends ConsumerStatefulWidget { + final PageController? controller; + final List items; + final double maxHeight; + + const MediaBanner({ + this.controller, + required this.items, + this.maxHeight = 250, + super.key, + }); + + @override + ConsumerState createState() => _MediaBannerState(); +} + +class _MediaBannerState extends ConsumerState { + bool showControls = false; + bool interacting = false; + int currentPage = 0; + double dragOffset = 0; + double dragIntensity = 1; + double slidePosition = 1; + + late final RestartableTimer timer = RestartableTimer(const Duration(seconds: 8), () => nextSlide()); + + @override + void initState() { + super.initState(); + timer.reset(); + } + + @override + void dispose() { + timer.cancel(); + super.dispose(); + } + + void nextSlide() { + if (!interacting) { + setState(() { + if (currentPage == widget.items.length - 1) { + currentPage = 0; + } else { + currentPage++; + } + }); + } + timer.reset(); + } + + void previousSlide() { + if (!interacting) { + setState(() { + if (currentPage == 0) { + currentPage = widget.items.length - 1; + } else { + currentPage--; + } + }); + } + timer.reset(); + } + + final controller = FladderCarouselController(); + + @override + Widget build(BuildContext context) { + final overlayColor = ThemesData.of(context).dark.colorScheme.onSecondary; + final shadows = [ + BoxShadow(blurRadius: 12, spreadRadius: 8, color: overlayColor), + ]; + final currentItem = widget.items[currentPage.clamp(0, widget.items.length - 1)]; + final actions = currentItem.generateActions(context, ref); + final double dragOpacity = (1 - dragOffset.abs()).clamp(0, 1); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: BoxConstraints(maxHeight: widget.maxHeight), + child: Card( + elevation: 16, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + surfaceTintColor: overlayColor, + color: overlayColor, + child: GestureDetector( + onTap: () => currentItem.navigateTo(context), + onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch + ? () async { + interacting = true; + final poster = currentItem; + showBottomSheetPill( + context: context, + item: poster, + content: (scrollContext, scrollController) => ListView( + shrinkWrap: true, + controller: scrollController, + children: poster.generateActions(context, ref).listTileItems(scrollContext, useIcons: true), + ), + ); + interacting = false; + timer.reset(); + } + : null, + child: MouseRegion( + onEnter: (event) => setState(() => showControls = true), + onHover: (event) => timer.reset(), + onExit: (event) => setState(() => showControls = false), + child: Stack( + fit: StackFit.expand, + children: [ + Dismissible( + key: const Key("Dismissable"), + direction: DismissDirection.horizontal, + onUpdate: (details) { + setState(() { + dragOffset = details.progress * 4; + }); + }, + confirmDismiss: (direction) async { + if (direction == DismissDirection.startToEnd) { + previousSlide(); + } else { + nextSlide(); + } + return false; + }, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 125), + opacity: dragOpacity.abs(), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 125), + child: Container( + key: Key(currentItem.id), + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.white.withOpacity(0.10), strokeAlign: BorderSide.strokeAlignInside), + gradient: LinearGradient( + begin: Alignment.bottomLeft, + end: Alignment.topCenter, + colors: [ + overlayColor.withOpacity(1), + overlayColor.withOpacity(0.75), + overlayColor.withOpacity(0.45), + overlayColor.withOpacity(0.15), + overlayColor.withOpacity(0), + overlayColor.withOpacity(0), + overlayColor.withOpacity(0.1), + ], + ), + ), + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Padding( + padding: const EdgeInsets.all(1), + child: FladderImage( + fit: BoxFit.cover, + image: currentItem.bannerImage, + ), + ), + ), + ), + ), + ), + ), + Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: IgnorePointer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + currentItem.title, + maxLines: 2, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + shadows: shadows, + color: Colors.white, + ), + ), + ), + if (currentItem.label(context) != null && currentItem is! MovieModel) + Flexible( + child: Text( + currentItem.label(context)!, + maxLines: 2, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + shadows: shadows, + color: Colors.white.withOpacity(0.75), + ), + ), + ), + if (currentItem.overview.summary.isNotEmpty && + AdaptiveLayout.layoutOf(context) != LayoutState.phone) + Flexible( + child: Text( + currentItem.overview.summary, + maxLines: 2, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + shadows: shadows, + color: Colors.white.withOpacity(0.75), + ), + ), + ), + ].addInBetween(const SizedBox(height: 6)), + ), + ), + ), + ].addInBetween(const SizedBox(height: 16)), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: AnimatedOpacity( + opacity: showControls ? 1 : 0, + duration: const Duration(milliseconds: 250), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton.filledTonal( + onPressed: () => nextSlide(), + icon: const Icon(IconsaxOutline.arrow_right_3), + ) + ], + ), + ), + ), + ], + ), + if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.all(16), + child: Card( + child: PopupMenuButton( + onOpened: () => interacting = true, + onCanceled: () { + interacting = false; + timer.reset(); + }, + itemBuilder: (context) => actions.popupMenuItems(useIcons: true), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + GestureDetector( + onHorizontalDragUpdate: (details) { + final delta = (details.primaryDelta ?? 0) / 20; + slidePosition += delta; + if (slidePosition > 1) { + nextSlide(); + slidePosition = 0; + } else if (slidePosition < -1) { + previousSlide(); + slidePosition = 0; + } + }, + onHorizontalDragStart: (details) { + slidePosition = 0; + }, + child: Container( + color: Colors.black.withOpacity(0), + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, + children: widget.items.mapIndexed((index, e) { + return Tooltip( + message: '${e.name}\n${e.detailedName}', + child: Card( + elevation: 0, + color: Colors.transparent, + child: InkWell( + onTapUp: currentPage == index + ? null + : (details) { + animateToTarget(index); + timer.reset(); + }, + child: Container( + alignment: Alignment.center, + color: Colors.red.withOpacity(0), + width: 28, + height: 28, + child: AnimatedContainer( + duration: const Duration(milliseconds: 125), + width: currentItem == e ? 22 : 6, + height: currentItem == e ? 10 : 6, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: currentItem == e + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.primary.withOpacity(0.25), + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ) + ], + ); + } + + void animateToTarget(int nextIndex) { + int step = currentPage < nextIndex ? 1 : -1; + void updateItem(int item) { + Future.delayed(Duration(milliseconds: 64 ~/ ((currentPage - nextIndex).abs() / 3)), () { + setState(() { + currentPage = item; + }); + + if (currentPage != nextIndex) { + updateItem(item + step); + } + }); + timer.reset(); + } + + updateItem(currentPage + step); + } +} diff --git a/lib/widgets/shared/fladder_carousel.dart b/lib/widgets/shared/fladder_carousel.dart new file mode 100644 index 0000000..e271a28 --- /dev/null +++ b/lib/widgets/shared/fladder_carousel.dart @@ -0,0 +1,752 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +//This is a copy of the CarouselView widget with some minor changes. + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class MyCustomScrollBehavior extends MaterialScrollBehavior { + @override + Set get dragDevices => PointerDeviceKind.values.toSet(); +} + +/// A Material Design carousel widget. +/// +/// The [FladderCarousel] present a scrollable list of items, each of which can dynamically +/// change size based on the chosen layout. +/// +/// This widget supports uncontained carousel layout. It shows items that scroll +/// to the edge of the container, behaving similarly to a [ListView] where all +/// children are a uniform size. +/// +/// The [FladderCarouselController] is used to control the [FladderCarouselController.initialItem]. +/// +/// The [FladderCarousel.itemExtent] property must be non-null and defines the base +/// size of items. While items typically maintain this size, the first and last +/// visible items may be slightly compressed during scrolling. The [shrinkExtent] +/// property controls the minimum allowable size for these compressed items. +/// +/// {@tool dartpad} +/// Here is an example of [FladderCarousel] to show the uncontained layout. Each carousel +/// item has the same size but can be "squished" to the [shrinkExtent] when they +/// are show on the view and out of view. +/// +/// ** See code in examples/api/lib/material/carousel/carousel.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [FladderCarouselController], which controls the first visible item in the carousel. +/// * [PageView], which is a scrollable list that works page by page. +class FladderCarousel extends StatefulWidget { + /// Creates a Material Design carousel. + const FladderCarousel({ + super.key, + this.padding, + this.backgroundColor, + this.elevation, + this.shape, + this.overlayColor, + this.itemSnapping = false, + this.shrinkExtent = 0.0, + this.controller, + this.scrollDirection = Axis.horizontal, + this.reverse = false, + this.onTap, + this.onLongPress, + this.onSecondaryTap, + required this.itemExtent, + required this.children, + }); + + /// The amount of space to surround each carousel item with. + /// + /// Defaults to [EdgeInsets.all] of 4 pixels. + final EdgeInsets? padding; + + /// The background color for each carousel item. + /// + /// Defaults to [ColorScheme.surface]. + final Color? backgroundColor; + + /// The z-coordinate of each carousel item. + /// + /// Defaults to 0.0. + final double? elevation; + + /// The shape of each carousel item's [Material]. + /// + /// Defines each item's [Material.shape]. + /// + /// Defaults to a [RoundedRectangleBorder] with a circular corner radius + /// of 28.0. + final ShapeBorder? shape; + + /// The highlight color to indicate the carousel items are in pressed, hovered + /// or focused states. + /// + /// The default values are: + /// * [WidgetState.pressed] - [ColorScheme.onSurface] with an opacity of 0.1 + /// * [WidgetState.hovered] - [ColorScheme.onSurface] with an opacity of 0.08 + /// * [WidgetState.focused] - [ColorScheme.onSurface] with an opacity of 0.1 + final WidgetStateProperty? overlayColor; + + /// The minimum allowable extent (size) in the main axis for carousel items + /// during scrolling transitions. + /// + /// As the carousel scrolls, the first visible item is pinned and gradually + /// shrinks until it reaches this minimum extent before scrolling off-screen. + /// Similarly, the last visible item enters the viewport at this minimum size + /// and expands to its full [itemExtent]. + /// + /// In cases where the remaining viewport space for the last visible item is + /// larger than the defined [shrinkExtent], the [shrinkExtent] is dynamically + /// adjusted to match this remaining space, ensuring a smooth size transition. + /// + /// Defaults to 0.0. Setting to 0.0 allows items to shrink/expand completely, + /// transitioning between 0.0 and the full [itemExtent]. In cases where the + /// remaining viewport space for the last visible item is larger than the + /// defined [shrinkExtent], the [shrinkExtent] is dynamically adjusted to match + /// this remaining space, ensuring a smooth size transition. + final double shrinkExtent; + + /// Whether the carousel should keep scrolling to the next/previous items to + /// maintain the original layout. + /// + /// Defaults to false. + final bool itemSnapping; + + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + final FladderCarouselController? controller; + + /// The [Axis] along which the scroll view's offset increases with each item. + /// + /// Defaults to [Axis.horizontal]. + final Axis scrollDirection; + + /// Whether the carousel list scrolls in the reading direction. + /// + /// For example, if the reading direction is left-to-right and + /// [scrollDirection] is [Axis.horizontal], then the carousel scrolls from + /// left to right when [reverse] is false and from right to left when + /// [reverse] is true. + /// + /// Similarly, if [scrollDirection] is [Axis.vertical], then the carousel view + /// scrolls from top to bottom when [reverse] is false and from bottom to top + /// when [reverse] is true. + /// + /// Defaults to false. + final bool reverse; + + /// Called when one of the [children] is tapped. + final ValueChanged? onTap; + + /// Called when one of the [children] is longPressed. + final ValueChanged? onLongPress; + + final ValueChanged<(int, TapDownDetails)>? onSecondaryTap; + + /// The extent the children are forced to have in the main axis. + /// + /// The item extent should not exceed the available space that the carousel + /// occupies to ensure at least one item is fully visible. + /// + /// This must be non-null. + final double itemExtent; + + /// The child widgets for the carousel. + final List children; + + @override + State createState() => _CarouselViewState(); +} + +class _CarouselViewState extends State { + late double _itemExtent; + FladderCarouselController? _internalController; + FladderCarouselController get _controller => widget.controller ?? _internalController!; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _internalController = FladderCarouselController(); + } + _controller._attach(this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _itemExtent = widget.itemExtent; + } + + @override + void didUpdateWidget(covariant FladderCarousel oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller?._detach(this); + if (widget.controller != null) { + _internalController?._detach(this); + _internalController = null; + widget.controller?._attach(this); + } else { + // widget.controller == null && oldWidget.controller != null + assert(_internalController == null); + _internalController = FladderCarouselController(); + _controller._attach(this); + } + } + if (widget.itemExtent != oldWidget.itemExtent) { + _itemExtent = widget.itemExtent; + } + } + + @override + void dispose() { + _controller._detach(this); + _internalController?.dispose(); + super.dispose(); + } + + AxisDirection _getDirection(BuildContext context) { + switch (widget.scrollDirection) { + case Axis.horizontal: + assert(debugCheckHasDirectionality(context)); + final TextDirection textDirection = Directionality.of(context); + final AxisDirection axisDirection = textDirectionToAxisDirection(textDirection); + return widget.reverse ? flipAxisDirection(axisDirection) : axisDirection; + case Axis.vertical: + return widget.reverse ? AxisDirection.up : AxisDirection.down; + } + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AxisDirection axisDirection = _getDirection(context); + final ScrollPhysics physics = + widget.itemSnapping ? const CarouselScrollPhysics() : ScrollConfiguration.of(context).getScrollPhysics(context); + final EdgeInsets effectivePadding = widget.padding ?? const EdgeInsets.all(4.0); + final Color effectiveBackgroundColor = widget.backgroundColor ?? Theme.of(context).colorScheme.surface; + final double effectiveElevation = widget.elevation ?? 0.0; + final ShapeBorder effectiveShape = + widget.shape ?? const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))); + + return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { + final double mainAxisExtent = switch (widget.scrollDirection) { + Axis.horizontal => constraints.maxWidth, + Axis.vertical => constraints.maxHeight, + }; + _itemExtent = clampDouble(_itemExtent, 0, mainAxisExtent); + + return Scrollable( + axisDirection: axisDirection, + scrollBehavior: MyCustomScrollBehavior(), + controller: _controller, + physics: physics, + viewportBuilder: (BuildContext context, ViewportOffset position) { + return Viewport( + cacheExtent: 0.0, + cacheExtentStyle: CacheExtentStyle.viewport, + axisDirection: axisDirection, + offset: position, + clipBehavior: Clip.antiAlias, + slivers: [ + _SliverFixedExtentCarousel( + itemExtent: _itemExtent, + minExtent: widget.shrinkExtent, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return Padding( + padding: effectivePadding, + child: Material( + clipBehavior: Clip.antiAlias, + color: effectiveBackgroundColor, + elevation: effectiveElevation, + shape: effectiveShape, + child: Stack( + fit: StackFit.expand, + children: [ + widget.children.elementAt(index), + Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onTap != null ? () => widget.onTap!.call(index) : null, + onLongPress: widget.onLongPress != null ? () => widget.onLongPress!.call(index) : null, + onSecondaryTapDown: widget.onSecondaryTap != null + ? (details) => widget.onSecondaryTap!.call((index, details)) + : null, + overlayColor: widget.overlayColor ?? + WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.pressed)) { + return theme.colorScheme.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return theme.colorScheme.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return theme.colorScheme.onSurface.withOpacity(0.1); + } + return null; + }), + ), + ), + ], + ), + ), + ); + }, + childCount: widget.children.length, + ), + ), + ], + ); + }, + ); + }); + } +} + +/// A sliver that displays its box children in a linear array with a fixed extent +/// per item. +/// +/// _To learn more about slivers, see [CustomScrollView.slivers]._ +/// +/// This sliver list arranges its children in a line along the main axis starting +/// at offset zero and without gaps. Each child is constrained to a fixed extent +/// along the main axis and the [SliverConstraints.crossAxisExtent] +/// along the cross axis. The difference between this and a list view with a fixed +/// extent is the first item and last item can be squished a little during scrolling +/// transition. This compression is controlled by the `minExtent` property and +/// aligns with the [Material Design Carousel specifications] +/// (https://m3.material.io/components/carousel/guidelines#96c5c157-fe5b-4ee3-a9b4-72bf8efab7e9). +class _SliverFixedExtentCarousel extends SliverMultiBoxAdaptorWidget { + const _SliverFixedExtentCarousel({ + required super.delegate, + required this.minExtent, + required this.itemExtent, + }); + + final double itemExtent; + final double minExtent; + + @override + RenderSliverFixedExtentBoxAdaptor createRenderObject(BuildContext context) { + final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement; + return _RenderSliverFixedExtentCarousel( + childManager: element, + minExtent: minExtent, + maxExtent: itemExtent, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSliverFixedExtentCarousel renderObject) { + renderObject.maxExtent = itemExtent; + renderObject.minExtent = minExtent; + } +} + +class _RenderSliverFixedExtentCarousel extends RenderSliverFixedExtentBoxAdaptor { + _RenderSliverFixedExtentCarousel({ + required super.childManager, + required double maxExtent, + required double minExtent, + }) : _maxExtent = maxExtent, + _minExtent = minExtent; + + double get maxExtent => _maxExtent; + double _maxExtent; + set maxExtent(double value) { + if (_maxExtent == value) { + return; + } + _maxExtent = value; + markNeedsLayout(); + } + + double get minExtent => _minExtent; + double _minExtent; + set minExtent(double value) { + if (_minExtent == value) { + return; + } + _minExtent = value; + markNeedsLayout(); + } + + // This implements the [itemExtentBuilder] callback. + double _buildItemExtent(int index, SliverLayoutDimensions currentLayoutDimensions) { + final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor(); + + // Calculate how many items have been completely scroll off screen. + final int offscreenItems = (constraints.scrollOffset / maxExtent).floor(); + + // If an item is partially off screen and partially on screen, + // `constraints.scrollOffset` must be greater than + // `offscreenItems * maxExtent`, so the difference between these two is how + // much the current first visible item is off screen. + final double offscreenExtent = constraints.scrollOffset - offscreenItems * maxExtent; + + // If there is not enough space to place the last visible item but the remaining + // space is larger than `minExtent`, the extent for last item should be at + // least the remaining extent to ensure a smooth size transition. + final double effectiveMinExtent = math.max(constraints.remainingPaintExtent % maxExtent, minExtent); + + // Two special cases are the first and last visible items. Other items' extent + // should all return `maxExtent`. + if (index == firstVisibleIndex) { + final double effectiveExtent = maxExtent - offscreenExtent; + return math.max(effectiveExtent, effectiveMinExtent); + } + + final double scrollOffsetForLastIndex = constraints.scrollOffset + constraints.remainingPaintExtent; + if (index == getMaxChildIndexForScrollOffset(scrollOffsetForLastIndex, maxExtent)) { + return clampDouble(scrollOffsetForLastIndex - maxExtent * index, effectiveMinExtent, maxExtent); + } + + return maxExtent; + } + + late SliverLayoutDimensions _currentLayoutDimensions; + + @override + void performLayout() { + _currentLayoutDimensions = SliverLayoutDimensions( + scrollOffset: constraints.scrollOffset, + precedingScrollExtent: constraints.precedingScrollExtent, + viewportMainAxisExtent: constraints.viewportMainAxisExtent, + crossAxisExtent: constraints.crossAxisExtent, + ); + super.performLayout(); + } + + /// The layout offset for the child with the given index. + @override + double indexToLayoutOffset( + @Deprecated('The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.') + double itemExtent, + int index, + ) { + final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor(); + + // If there is not enough space to place the last visible item but the remaining + // space is larger than `minExtent`, the extent for last item should be at + // least the remaining extent to make sure a smooth size transition. + final double effectiveMinExtent = math.max(constraints.remainingPaintExtent % maxExtent, minExtent); + if (index == firstVisibleIndex) { + final double firstVisibleItemExtent = _buildItemExtent(index, _currentLayoutDimensions); + + // If the first item is squished to be less than `effectievMinExtent`, + // then it should stop changinng its size and should start to scroll off screen. + if (firstVisibleItemExtent <= effectiveMinExtent) { + return maxExtent * index - effectiveMinExtent + maxExtent; + } + return constraints.scrollOffset; + } + return maxExtent * index; + } + + /// The minimum child index that is visible at the given scroll offset. + @override + int getMinChildIndexForScrollOffset( + double scrollOffset, + @Deprecated('The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.') + double itemExtent, + ) { + final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor(); + return math.max(firstVisibleIndex, 0); + } + + /// The maximum child index that is visible at the given scroll offset. + @override + int getMaxChildIndexForScrollOffset( + double scrollOffset, + @Deprecated('The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.') + double itemExtent, + ) { + if (maxExtent > 0.0) { + final double actual = scrollOffset / maxExtent - 1; + final int round = actual.round(); + if ((actual * maxExtent - round * maxExtent).abs() < precisionErrorTolerance) { + return math.max(0, round); + } + return math.max(0, actual.ceil()); + } + return 0; + } + + @override + double? get itemExtent => null; + + @override + ItemExtentBuilder? get itemExtentBuilder => _buildItemExtent; +} + +/// Scroll physics used by a [FladderCarousel]. +/// +/// These physics cause the carousel item to snap to item boundaries. +/// +/// See also: +/// +/// * [ScrollPhysics], the base class which defines the API for scrolling +/// physics. +/// * [PageScrollPhysics], scroll physics used by a [PageView]. +class CarouselScrollPhysics extends ScrollPhysics { + /// Creates physics for a [FladderCarousel]. + const CarouselScrollPhysics({super.parent}); + + @override + CarouselScrollPhysics applyTo(ScrollPhysics? ancestor) { + return CarouselScrollPhysics(parent: buildParent(ancestor)); + } + + double _getTargetPixels( + _CarouselPosition position, + Tolerance tolerance, + double velocity, + ) { + double fraction; + fraction = position.itemExtent! / position.viewportDimension; + + final double itemWidth = position.viewportDimension * fraction; + + final double actual = math.max(0.0, position.pixels) / itemWidth; + final double round = actual.roundToDouble(); + double item; + if ((actual - round).abs() < precisionErrorTolerance) { + item = round; + } else { + item = actual; + } + if (velocity < -tolerance.velocity) { + item -= 0.5; + } else if (velocity > tolerance.velocity) { + item += 0.5; + } + return item.roundToDouble() * itemWidth; + } + + @override + Simulation? createBallisticSimulation( + ScrollMetrics position, + double velocity, + ) { + assert( + position is _CarouselPosition, + 'CarouselScrollPhysics can only be used with Scrollables that uses ' + 'the FladderCarouselController', + ); + + final _CarouselPosition metrics = position as _CarouselPosition; + if ((velocity <= 0.0 && metrics.pixels <= metrics.minScrollExtent) || + (velocity >= 0.0 && metrics.pixels >= metrics.maxScrollExtent)) { + return super.createBallisticSimulation(metrics, velocity); + } + + final Tolerance tolerance = toleranceFor(metrics); + final double target = _getTargetPixels(metrics, tolerance, velocity); + if (target != metrics.pixels) { + return ScrollSpringSimulation( + spring, + metrics.pixels, + target, + velocity, + tolerance: tolerance, + ); + } + return null; + } + + @override + bool get allowImplicitScrolling => true; +} + +/// Metrics for a [FladderCarousel]. +class _CarouselMetrics extends FixedScrollMetrics { + /// Creates an immutable snapshot of values associated with a [FladderCarousel]. + _CarouselMetrics({ + required super.minScrollExtent, + required super.maxScrollExtent, + required super.pixels, + required super.viewportDimension, + required super.axisDirection, + this.itemExtent, + required super.devicePixelRatio, + }); + + /// Extent for the carousel item. + /// + /// Used to compute the first item from the current [pixels]. + final double? itemExtent; + + @override + _CarouselMetrics copyWith({ + double? minScrollExtent, + double? maxScrollExtent, + double? pixels, + double? viewportDimension, + AxisDirection? axisDirection, + double? itemExtent, + double? devicePixelRatio, + }) { + return _CarouselMetrics( + minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null), + maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null), + pixels: pixels ?? (hasPixels ? this.pixels : null), + viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null), + axisDirection: axisDirection ?? this.axisDirection, + itemExtent: itemExtent ?? this.itemExtent, + devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + ); + } +} + +class _CarouselPosition extends ScrollPositionWithSingleContext implements _CarouselMetrics { + _CarouselPosition({ + required super.physics, + required super.context, + this.initialItem = 0, + required this.itemExtent, + super.oldPosition, + }) : _itemToShowOnStartup = initialItem.toDouble(), + super(initialPixels: null); + + final int initialItem; + final double _itemToShowOnStartup; + // When the viewport has a zero-size, the item can not + // be retrieved by `getItemFromPixels`, so we need to cache the item + // for use when resizing the viewport to non-zero next time. + double? _cachedItem; + + @override + double? itemExtent; + + double getItemFromPixels(double pixels, double viewportDimension) { + assert(viewportDimension > 0.0); + final double fraction = itemExtent! / viewportDimension; + + final double actual = math.max(0.0, pixels) / (viewportDimension * fraction); + final double round = actual.roundToDouble(); + if ((actual - round).abs() < precisionErrorTolerance) { + return round; + } + return actual; + } + + double getPixelsFromItem(double item) { + final double fraction = itemExtent! / viewportDimension; + + return item * viewportDimension * fraction; + } + + @override + bool applyViewportDimension(double viewportDimension) { + final double? oldViewportDimensions = hasViewportDimension ? this.viewportDimension : null; + if (viewportDimension == oldViewportDimensions) { + return true; + } + final bool result = super.applyViewportDimension(viewportDimension); + final double? oldPixels = hasPixels ? pixels : null; + double item; + if (oldPixels == null) { + item = _itemToShowOnStartup; + } else if (oldViewportDimensions == 0.0) { + // If resize from zero, we should use the _cachedItem to recover the state. + item = _cachedItem!; + } else { + item = getItemFromPixels(oldPixels, oldViewportDimensions!); + } + final double newPixels = getPixelsFromItem(item); + // If the viewportDimension is zero, cache the item + // in case the viewport is resized to be non-zero. + _cachedItem = (viewportDimension == 0.0) ? item : null; + + if (newPixels != oldPixels) { + correctPixels(newPixels); + return false; + } + return result; + } + + @override + _CarouselMetrics copyWith({ + double? minScrollExtent, + double? maxScrollExtent, + double? pixels, + double? viewportDimension, + AxisDirection? axisDirection, + double? itemExtent, + List? layoutWeights, + double? devicePixelRatio, + }) { + return _CarouselMetrics( + minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null), + maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null), + pixels: pixels ?? (hasPixels ? this.pixels : null), + viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null), + axisDirection: axisDirection ?? this.axisDirection, + itemExtent: itemExtent ?? this.itemExtent, + devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + ); + } +} + +/// A controller for [FladderCarousel]. +/// +/// Using a carousel controller helps to show the first visible item on the +/// carousel list. +class FladderCarouselController extends ScrollController { + /// Creates a carousel controller. + FladderCarouselController({ + this.initialItem = 0, + }); + + /// The item that expands to the maximum size when first creating the [FladderCarousel]. + final int initialItem; + + _CarouselViewState? _carouselState; + + // ignore: use_setters_to_change_properties + void _attach(_CarouselViewState anchor) { + _carouselState = anchor; + } + + void _detach(_CarouselViewState anchor) { + if (_carouselState == anchor) { + _carouselState = null; + } + } + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { + assert(_carouselState != null); + final double itemExtent = _carouselState!._itemExtent; + + return _CarouselPosition( + physics: physics, + context: context, + initialItem: initialItem, + itemExtent: itemExtent, + oldPosition: oldPosition, + ); + } + + @override + void attach(ScrollPosition position) { + super.attach(position); + final _CarouselPosition carouselPosition = position as _CarouselPosition; + carouselPosition.itemExtent = _carouselState!._itemExtent; + } +}