diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 66595225..b68dbd7d 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -14,7 +14,7 @@ import '../utils/snackbar_helper.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../i18n/strings.g.dart'; -import 'libraries/state_messages.dart'; +import 'libraries/content_state_builder.dart'; /// Abstract base class for screens displaying media lists (collections/playlists) /// Provides common state management and playback functionality @@ -117,11 +117,7 @@ abstract class BaseMediaListDetailScreen extends State /// Returns a list of slivers to display based on current state List buildStateSlivers() { if (errorMessage != null) { - return [ - SliverFillRemaining( - child: ErrorStateWidget(message: errorMessage!, icon: Symbols.error_outline_rounded, onRetry: loadItems), - ), - ]; + return [SliverErrorState(message: errorMessage!, onRetry: loadItems)]; } if (items.isEmpty && isLoading) { @@ -129,11 +125,7 @@ abstract class BaseMediaListDetailScreen extends State } if (items.isEmpty) { - return [ - SliverFillRemaining( - child: EmptyStateWidget(message: emptyMessage, icon: emptyIcon), - ), - ]; + return [SliverEmptyState(message: emptyMessage, icon: emptyIcon)]; } return []; diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index dd9fea82..cfa241d9 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -25,7 +25,7 @@ class SliverErrorState extends StatelessWidget { /// Sliver wrapper around [EmptyStateWidget] for use in `CustomScrollView.slivers`. class SliverEmptyState extends StatelessWidget { final String message; - final IconData icon; + final IconData? icon; final String? subtitle; const SliverEmptyState({super.key, required this.message, required this.icon, this.subtitle}); diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 31ba8b34..7098da86 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -13,8 +13,8 @@ import '../../services/settings_service.dart' as settings show ThemeMode; import '../../focus/focusable_slider.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/app_icon.dart'; -import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/setting_tile.dart'; +import '../../widgets/settings_page.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_section.dart'; import 'settings_utils.dart'; @@ -24,130 +24,126 @@ class AppearanceSettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return FocusedScrollScaffold( + return SettingsPage( title: Text(t.settings.appearance), - slivers: [ - SliverList( - delegate: SliverChildListDelegate([ - SettingsSectionHeader(t.settings.display), - _themeSelector(), - _languageSelector(context), - _densitySelector(), - _viewModeSelector(), - _episodePosterModeSelector(), - SettingSwitchTile( - pref: SettingsService.showEpisodeNumberOnCards, - icon: Symbols.tag_rounded, - title: t.settings.showEpisodeNumberOnCards, - subtitle: t.settings.showEpisodeNumberOnCardsDescription, - ), - SettingSwitchTile( - pref: SettingsService.showSeasonPostersOnTabs, - icon: Symbols.image_rounded, - title: t.settings.showSeasonPostersOnTabs, - subtitle: t.settings.showSeasonPostersOnTabsDescription, - ), - - SettingsSectionHeader(t.settings.homeScreen), - SettingSwitchTile( - pref: SettingsService.showHeroSection, - icon: Symbols.featured_play_list_rounded, - title: t.settings.showHeroSection, - subtitle: t.settings.showHeroSectionDescription, - ), - SettingSwitchTile( - pref: SettingsService.useGlobalHubs, - icon: Symbols.home_rounded, - title: t.settings.useGlobalHubs, - subtitle: t.settings.useGlobalHubsDescription, - ), - SettingSwitchTile( - pref: SettingsService.showServerNameOnHubs, - icon: Symbols.dns_rounded, - title: t.settings.showServerNameOnHubs, - subtitle: t.settings.showServerNameOnHubsDescription, - ), - - SettingsSectionHeader(t.settings.navigation), - if (Platform.isAndroid) - SettingSwitchTile( - pref: SettingsService.forceTvMode, - icon: Symbols.tv_rounded, - title: t.settings.forceTvMode, - subtitle: t.settings.forceTvModeDescription, - onAfterWrite: (value) { - TvDetectionService.setForceTVSync(value); - _restartApp(context); - }, - ), - if (PlatformDetector.shouldUseSideNavigation(context)) - SettingSwitchTile( - pref: SettingsService.alwaysKeepSidebarOpen, - icon: Symbols.dock_to_left_rounded, - title: t.settings.alwaysKeepSidebarOpen, - subtitle: t.settings.alwaysKeepSidebarOpenDescription, - ), - if (PlatformDetector.shouldUseSideNavigation(context)) - SettingSwitchTile( - pref: SettingsService.groupLibrariesByServer, - icon: Symbols.dns_rounded, - title: t.settings.groupLibrariesByServer, - subtitle: t.settings.groupLibrariesByServerDescription, - ), - if (!PlatformDetector.shouldUseSideNavigation(context)) - SettingSwitchTile( - pref: SettingsService.showNavBarLabels, - icon: Symbols.label_rounded, - title: t.settings.showNavBarLabels, - subtitle: t.settings.showNavBarLabelsDescription, - ), - SettingSwitchTile( - pref: SettingsService.showUnwatchedCount, - icon: Symbols.counter_1_rounded, - title: t.settings.showUnwatchedCount, - subtitle: t.settings.showUnwatchedCountDescription, - ), - - if (Platform.isWindows || Platform.isLinux) ...[ - SettingsSectionHeader(t.settings.window), - SettingSwitchTile( - pref: SettingsService.startInFullscreen, - icon: Symbols.fullscreen_rounded, - title: t.settings.startInFullscreen, - subtitle: t.settings.startInFullscreenDescription, - ), - ], - - SettingsSectionHeader(t.settings.content), - SettingSwitchTile( - pref: SettingsService.liveTvDefaultFavorites, - icon: Symbols.star_rounded, - title: t.settings.liveTvDefaultFavorites, - subtitle: t.settings.liveTvDefaultFavoritesDescription, - ), - SettingSwitchTile( - pref: SettingsService.hideSpoilers, - icon: Symbols.visibility_off_rounded, - title: t.settings.hideSpoilers, - subtitle: t.settings.hideSpoilersDescription, - ), - _requireProfileSelection(), - if (PlatformDetector.isTV()) - SettingSwitchTile( - pref: SettingsService.confirmExitOnBack, - icon: Symbols.exit_to_app_rounded, - title: t.settings.confirmExitOnBack, - subtitle: t.settings.confirmExitOnBackDescription, - ), - SettingSwitchTile( - pref: SettingsService.autoHidePerformanceOverlay, - icon: Symbols.speed_rounded, - title: t.settings.autoHidePerformanceOverlay, - subtitle: t.settings.autoHidePerformanceOverlayDescription, - ), - const SizedBox(height: 24), - ]), + children: [ + SettingsSectionHeader(t.settings.display), + _themeSelector(), + _languageSelector(context), + _densitySelector(), + _viewModeSelector(), + _episodePosterModeSelector(), + SettingSwitchTile( + pref: SettingsService.showEpisodeNumberOnCards, + icon: Symbols.tag_rounded, + title: t.settings.showEpisodeNumberOnCards, + subtitle: t.settings.showEpisodeNumberOnCardsDescription, ), + SettingSwitchTile( + pref: SettingsService.showSeasonPostersOnTabs, + icon: Symbols.image_rounded, + title: t.settings.showSeasonPostersOnTabs, + subtitle: t.settings.showSeasonPostersOnTabsDescription, + ), + + SettingsSectionHeader(t.settings.homeScreen), + SettingSwitchTile( + pref: SettingsService.showHeroSection, + icon: Symbols.featured_play_list_rounded, + title: t.settings.showHeroSection, + subtitle: t.settings.showHeroSectionDescription, + ), + SettingSwitchTile( + pref: SettingsService.useGlobalHubs, + icon: Symbols.home_rounded, + title: t.settings.useGlobalHubs, + subtitle: t.settings.useGlobalHubsDescription, + ), + SettingSwitchTile( + pref: SettingsService.showServerNameOnHubs, + icon: Symbols.dns_rounded, + title: t.settings.showServerNameOnHubs, + subtitle: t.settings.showServerNameOnHubsDescription, + ), + + SettingsSectionHeader(t.settings.navigation), + if (Platform.isAndroid) + SettingSwitchTile( + pref: SettingsService.forceTvMode, + icon: Symbols.tv_rounded, + title: t.settings.forceTvMode, + subtitle: t.settings.forceTvModeDescription, + onAfterWrite: (value) { + TvDetectionService.setForceTVSync(value); + _restartApp(context); + }, + ), + if (PlatformDetector.shouldUseSideNavigation(context)) + SettingSwitchTile( + pref: SettingsService.alwaysKeepSidebarOpen, + icon: Symbols.dock_to_left_rounded, + title: t.settings.alwaysKeepSidebarOpen, + subtitle: t.settings.alwaysKeepSidebarOpenDescription, + ), + if (PlatformDetector.shouldUseSideNavigation(context)) + SettingSwitchTile( + pref: SettingsService.groupLibrariesByServer, + icon: Symbols.dns_rounded, + title: t.settings.groupLibrariesByServer, + subtitle: t.settings.groupLibrariesByServerDescription, + ), + if (!PlatformDetector.shouldUseSideNavigation(context)) + SettingSwitchTile( + pref: SettingsService.showNavBarLabels, + icon: Symbols.label_rounded, + title: t.settings.showNavBarLabels, + subtitle: t.settings.showNavBarLabelsDescription, + ), + SettingSwitchTile( + pref: SettingsService.showUnwatchedCount, + icon: Symbols.counter_1_rounded, + title: t.settings.showUnwatchedCount, + subtitle: t.settings.showUnwatchedCountDescription, + ), + + if (Platform.isWindows || Platform.isLinux) ...[ + SettingsSectionHeader(t.settings.window), + SettingSwitchTile( + pref: SettingsService.startInFullscreen, + icon: Symbols.fullscreen_rounded, + title: t.settings.startInFullscreen, + subtitle: t.settings.startInFullscreenDescription, + ), + ], + + SettingsSectionHeader(t.settings.content), + SettingSwitchTile( + pref: SettingsService.liveTvDefaultFavorites, + icon: Symbols.star_rounded, + title: t.settings.liveTvDefaultFavorites, + subtitle: t.settings.liveTvDefaultFavoritesDescription, + ), + SettingSwitchTile( + pref: SettingsService.hideSpoilers, + icon: Symbols.visibility_off_rounded, + title: t.settings.hideSpoilers, + subtitle: t.settings.hideSpoilersDescription, + ), + _requireProfileSelection(), + if (PlatformDetector.isTV()) + SettingSwitchTile( + pref: SettingsService.confirmExitOnBack, + icon: Symbols.exit_to_app_rounded, + title: t.settings.confirmExitOnBack, + subtitle: t.settings.confirmExitOnBackDescription, + ), + SettingSwitchTile( + pref: SettingsService.autoHidePerformanceOverlay, + icon: Symbols.speed_rounded, + title: t.settings.autoHidePerformanceOverlay, + subtitle: t.settings.autoHidePerformanceOverlayDescription, + ), + const SizedBox(height: 24), ], ); } diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index a26f9e78..8fe2122e 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -10,9 +10,9 @@ import '../../focus/focusable_text_field.dart'; import '../../i18n/strings.g.dart'; import '../../models/external_player_models.dart'; import '../../services/settings_service.dart'; -import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; +import '../../widgets/settings_page.dart'; import '../../widgets/settings_section.dart'; class ExternalPlayerScreen extends StatelessWidget { @@ -21,46 +21,42 @@ class ExternalPlayerScreen extends StatelessWidget { @override Widget build(BuildContext context) { final knownPlayers = KnownPlayers.getForCurrentPlatform(); - return FocusedScrollScaffold( + return SettingsPage( title: Text(t.externalPlayer.title), - slivers: [ - SliverList( - delegate: SliverChildListDelegate([ - SettingSwitchTile( - pref: SettingsService.useExternalPlayer, - icon: Symbols.open_in_new_rounded, - title: t.externalPlayer.useExternalPlayer, - subtitle: t.externalPlayer.useExternalPlayerDescription, - ), - SettingsBuilder( - prefs: [ - SettingsService.useExternalPlayer, - SettingsService.selectedExternalPlayer, - SettingsService.customExternalPlayers, - ], - builder: (context) { - final svc = SettingsService.instanceOrNull!; - if (!svc.read(SettingsService.useExternalPlayer)) return const SizedBox.shrink(); - final selected = svc.read(SettingsService.selectedExternalPlayer); - final custom = svc.read(SettingsService.customExternalPlayers); - return Column( - children: [ - SettingsSectionHeader(t.externalPlayer.selectPlayer), - ...knownPlayers.map((p) => _PlayerTile(player: p, selectedId: selected.id)), - SettingsSectionHeader(t.externalPlayer.customPlayers), - ...custom.map((p) => _PlayerTile(player: p, selectedId: selected.id, isCustom: true)), - ListTile( - leading: const AppIcon(Symbols.add_rounded, fill: 1), - title: Text(t.externalPlayer.addCustomPlayer), - onTap: () => _showAddCustomPlayerDialog(context), - ), - ], - ); - }, - ), - const SizedBox(height: 24), - ]), + children: [ + SettingSwitchTile( + pref: SettingsService.useExternalPlayer, + icon: Symbols.open_in_new_rounded, + title: t.externalPlayer.useExternalPlayer, + subtitle: t.externalPlayer.useExternalPlayerDescription, ), + SettingsBuilder( + prefs: [ + SettingsService.useExternalPlayer, + SettingsService.selectedExternalPlayer, + SettingsService.customExternalPlayers, + ], + builder: (context) { + final svc = SettingsService.instanceOrNull!; + if (!svc.read(SettingsService.useExternalPlayer)) return const SizedBox.shrink(); + final selected = svc.read(SettingsService.selectedExternalPlayer); + final custom = svc.read(SettingsService.customExternalPlayers); + return Column( + children: [ + SettingsSectionHeader(t.externalPlayer.selectPlayer), + ...knownPlayers.map((p) => _PlayerTile(player: p, selectedId: selected.id)), + SettingsSectionHeader(t.externalPlayer.customPlayers), + ...custom.map((p) => _PlayerTile(player: p, selectedId: selected.id, isCustom: true)), + ListTile( + leading: const AppIcon(Symbols.add_rounded, fill: 1), + title: Text(t.externalPlayer.addCustomPlayer), + onTap: () => _showAddCustomPlayerDialog(context), + ), + ], + ); + }, + ), + const SizedBox(height: 24), ], ); } diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index 65ea44c6..522ae835 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -12,9 +12,9 @@ import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart'; import '../../utils/platform_detector.dart'; import '../../utils/snackbar_helper.dart'; -import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; +import '../../widgets/settings_page.dart'; import '../../widgets/settings_section.dart'; import 'external_player_screen.dart'; import 'mpv_config_screen.dart'; @@ -45,163 +45,159 @@ class _PlaybackSettingsScreenState extends State { Widget build(BuildContext context) { final isMobile = PlatformDetector.isMobile(context); - return FocusedScrollScaffold( + return SettingsPage( title: Text(t.settings.videoPlayback), - slivers: [ - SliverList( - delegate: SliverChildListDelegate([ - SettingsSectionHeader(t.settings.player), - if (Platform.isAndroid) _playerBackendSelector(), - _externalPlayerTile(), - _hardwareDecodingTile(), - if ((Platform.isAndroid && !PlatformDetector.isTV()) || Platform.isIOS || Platform.isMacOS) _autoPipTile(), - if (Platform.isAndroid) _matchContentFrameRateTile(), - if (Platform.isWindows) _matchRefreshRateTile(), - if (Platform.isWindows) _matchDynamicRangeTile(), - _displaySwitchDelayTile(), - _tunneledPlaybackTile(), - _bufferSizeTile(), - _defaultQualityTile(), + children: [ + SettingsSectionHeader(t.settings.player), + if (Platform.isAndroid) _playerBackendSelector(), + _externalPlayerTile(), + _hardwareDecodingTile(), + if ((Platform.isAndroid && !PlatformDetector.isTV()) || Platform.isIOS || Platform.isMacOS) _autoPipTile(), + if (Platform.isAndroid) _matchContentFrameRateTile(), + if (Platform.isWindows) _matchRefreshRateTile(), + if (Platform.isWindows) _matchDynamicRangeTile(), + _displaySwitchDelayTile(), + _tunneledPlaybackTile(), + _bufferSizeTile(), + _defaultQualityTile(), - SettingsSectionHeader(t.settings.subtitlesAndConfig), - SettingNavigationTile( - icon: Symbols.subtitles_rounded, - title: t.settings.subtitleStyling, - subtitle: t.settings.subtitleStylingDescription, - destinationBuilder: (_) => const SubtitleStylingScreen(), - ), - _mpvConfigTile(), - - SettingsSectionHeader(t.settings.seekAndTiming), - SettingNumberTile( - pref: SettingsService.seekTimeSmall, - icon: Symbols.replay_10_rounded, - title: t.settings.smallSkipDuration, - subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 120, - onAfterWrite: (_) => _keyboardService?.refreshFromStorage(), - ), - SettingNumberTile( - pref: SettingsService.seekTimeLarge, - icon: Symbols.replay_30_rounded, - title: t.settings.largeSkipDuration, - subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 120, - onAfterWrite: (_) => _keyboardService?.refreshFromStorage(), - ), - SettingNumberTile( - pref: SettingsService.rewindOnResume, - icon: Symbols.replay_rounded, - title: t.settings.rewindOnResume, - subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 0, - max: 10, - ), - SettingNumberTile( - pref: SettingsService.sleepTimerDuration, - icon: Symbols.bedtime_rounded, - title: t.settings.defaultSleepTimer, - subtitleBuilder: (v) => t.settings.minutesUnit(minutes: v.toString()), - labelText: t.settings.minutesLabel, - suffixText: t.settings.minutesShort, - min: 5, - max: 240, - ), - SettingNumberTile( - pref: SettingsService.maxVolume, - icon: Symbols.volume_up_rounded, - title: t.settings.maxVolume, - subtitleBuilder: (v) => t.settings.maxVolumePercent(percent: v.toString()), - labelText: t.settings.maxVolumeDescription, - suffixText: '%', - min: 100, - max: 300, - ), - - SettingsSectionHeader(t.settings.behavior), - if (DiscordRPCService.isAvailable) - SettingSwitchTile( - pref: SettingsService.enableDiscordRPC, - icon: Symbols.chat_rounded, - title: t.settings.discordRichPresence, - subtitle: t.settings.discordRichPresenceDescription, - onAfterWrite: (v) => DiscordRPCService.instance.setEnabled(v), - ), - if (PlatformDetector.shouldActAsRemoteHost(context)) - SettingSwitchTile( - pref: SettingsService.enableCompanionRemoteServer, - icon: Symbols.phone_android_rounded, - title: t.settings.companionRemoteServer, - subtitle: t.settings.companionRemoteServerDescription, - ), - SettingSwitchTile( - pref: SettingsService.rememberTrackSelections, - icon: Symbols.bookmark_rounded, - title: t.settings.rememberTrackSelections, - subtitle: t.settings.rememberTrackSelectionsDescription, - ), - if (!isMobile) - SettingSwitchTile( - pref: SettingsService.clickVideoTogglesPlayback, - icon: Symbols.play_pause_rounded, - title: t.settings.clickVideoTogglesPlayback, - subtitle: t.settings.clickVideoTogglesPlaybackDescription, - ), - - SettingsSectionHeader(t.settings.autoSkip), - SettingSwitchTile( - pref: SettingsService.autoSkipIntro, - icon: Symbols.fast_forward_rounded, - title: t.settings.autoSkipIntro, - subtitle: t.settings.autoSkipIntroDescription, - ), - SettingSwitchTile( - pref: SettingsService.autoSkipCredits, - icon: Symbols.skip_next_rounded, - title: t.settings.autoSkipCredits, - subtitle: t.settings.autoSkipCreditsDescription, - ), - SettingSwitchTile( - pref: SettingsService.forceSkipMarkerFallback, - icon: Symbols.tune_rounded, - title: t.settings.forceSkipMarkerFallback, - subtitle: t.settings.forceSkipMarkerFallbackDescription, - ), - SettingNumberTile( - pref: SettingsService.autoSkipDelay, - icon: Symbols.timer_rounded, - title: t.settings.autoSkipDelay, - subtitleBuilder: (v) => t.settings.autoSkipDelayDescription(seconds: v.toString()), - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 30, - ), - SettingRegexTile( - pref: SettingsService.introPattern, - icon: Symbols.match_case_rounded, - title: t.settings.introPattern, - subtitle: t.settings.introPatternDescription, - defaultValue: SettingsService.defaultIntroPattern, - ), - SettingRegexTile( - pref: SettingsService.creditsPattern, - icon: Symbols.match_case_rounded, - title: t.settings.creditsPattern, - subtitle: t.settings.creditsPatternDescription, - defaultValue: SettingsService.defaultCreditsPattern, - ), - const SizedBox(height: 24), - ]), + SettingsSectionHeader(t.settings.subtitlesAndConfig), + SettingNavigationTile( + icon: Symbols.subtitles_rounded, + title: t.settings.subtitleStyling, + subtitle: t.settings.subtitleStylingDescription, + destinationBuilder: (_) => const SubtitleStylingScreen(), ), + _mpvConfigTile(), + + SettingsSectionHeader(t.settings.seekAndTiming), + SettingNumberTile( + pref: SettingsService.seekTimeSmall, + icon: Symbols.replay_10_rounded, + title: t.settings.smallSkipDuration, + subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 120, + onAfterWrite: (_) => _keyboardService?.refreshFromStorage(), + ), + SettingNumberTile( + pref: SettingsService.seekTimeLarge, + icon: Symbols.replay_30_rounded, + title: t.settings.largeSkipDuration, + subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 120, + onAfterWrite: (_) => _keyboardService?.refreshFromStorage(), + ), + SettingNumberTile( + pref: SettingsService.rewindOnResume, + icon: Symbols.replay_rounded, + title: t.settings.rewindOnResume, + subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 0, + max: 10, + ), + SettingNumberTile( + pref: SettingsService.sleepTimerDuration, + icon: Symbols.bedtime_rounded, + title: t.settings.defaultSleepTimer, + subtitleBuilder: (v) => t.settings.minutesUnit(minutes: v.toString()), + labelText: t.settings.minutesLabel, + suffixText: t.settings.minutesShort, + min: 5, + max: 240, + ), + SettingNumberTile( + pref: SettingsService.maxVolume, + icon: Symbols.volume_up_rounded, + title: t.settings.maxVolume, + subtitleBuilder: (v) => t.settings.maxVolumePercent(percent: v.toString()), + labelText: t.settings.maxVolumeDescription, + suffixText: '%', + min: 100, + max: 300, + ), + + SettingsSectionHeader(t.settings.behavior), + if (DiscordRPCService.isAvailable) + SettingSwitchTile( + pref: SettingsService.enableDiscordRPC, + icon: Symbols.chat_rounded, + title: t.settings.discordRichPresence, + subtitle: t.settings.discordRichPresenceDescription, + onAfterWrite: (v) => DiscordRPCService.instance.setEnabled(v), + ), + if (PlatformDetector.shouldActAsRemoteHost(context)) + SettingSwitchTile( + pref: SettingsService.enableCompanionRemoteServer, + icon: Symbols.phone_android_rounded, + title: t.settings.companionRemoteServer, + subtitle: t.settings.companionRemoteServerDescription, + ), + SettingSwitchTile( + pref: SettingsService.rememberTrackSelections, + icon: Symbols.bookmark_rounded, + title: t.settings.rememberTrackSelections, + subtitle: t.settings.rememberTrackSelectionsDescription, + ), + if (!isMobile) + SettingSwitchTile( + pref: SettingsService.clickVideoTogglesPlayback, + icon: Symbols.play_pause_rounded, + title: t.settings.clickVideoTogglesPlayback, + subtitle: t.settings.clickVideoTogglesPlaybackDescription, + ), + + SettingsSectionHeader(t.settings.autoSkip), + SettingSwitchTile( + pref: SettingsService.autoSkipIntro, + icon: Symbols.fast_forward_rounded, + title: t.settings.autoSkipIntro, + subtitle: t.settings.autoSkipIntroDescription, + ), + SettingSwitchTile( + pref: SettingsService.autoSkipCredits, + icon: Symbols.skip_next_rounded, + title: t.settings.autoSkipCredits, + subtitle: t.settings.autoSkipCreditsDescription, + ), + SettingSwitchTile( + pref: SettingsService.forceSkipMarkerFallback, + icon: Symbols.tune_rounded, + title: t.settings.forceSkipMarkerFallback, + subtitle: t.settings.forceSkipMarkerFallbackDescription, + ), + SettingNumberTile( + pref: SettingsService.autoSkipDelay, + icon: Symbols.timer_rounded, + title: t.settings.autoSkipDelay, + subtitleBuilder: (v) => t.settings.autoSkipDelayDescription(seconds: v.toString()), + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 30, + ), + SettingRegexTile( + pref: SettingsService.introPattern, + icon: Symbols.match_case_rounded, + title: t.settings.introPattern, + subtitle: t.settings.introPatternDescription, + defaultValue: SettingsService.defaultIntroPattern, + ), + SettingRegexTile( + pref: SettingsService.creditsPattern, + icon: Symbols.match_case_rounded, + title: t.settings.creditsPattern, + subtitle: t.settings.creditsPatternDescription, + defaultValue: SettingsService.defaultCreditsPattern, + ), + const SizedBox(height: 24), ], ); } diff --git a/lib/screens/settings/subtitle_styling_screen.dart b/lib/screens/settings/subtitle_styling_screen.dart index e7d83a5f..cd67ee3b 100644 --- a/lib/screens/settings/subtitle_styling_screen.dart +++ b/lib/screens/settings/subtitle_styling_screen.dart @@ -3,8 +3,8 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../i18n/strings.g.dart'; import '../../services/settings_service.dart'; -import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/setting_tile.dart'; +import '../../widgets/settings_page.dart'; import '../../widgets/settings_section.dart'; import 'settings_utils.dart'; @@ -29,93 +29,89 @@ class SubtitleStylingScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return FocusedScrollScaffold( + return SettingsPage( title: Text(t.screens.subtitleStyling), - slivers: [ - SliverList( - delegate: SliverChildListDelegate([ - SettingsSectionHeader(t.subtitlingStyling.text), - SettingSelectionTile( - pref: SettingsService.subAssOverride, - icon: Symbols.subtitles_rounded, - title: t.subtitlingStyling.assOverride, - subtitleBuilder: _assOverrideLabel, - options: SubAssOverride.values.map((v) => DialogOption(value: v, title: _assOverrideLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, - ), - SettingNumberTile( - pref: SettingsService.subtitleFontSize, - icon: Symbols.format_size_rounded, - title: t.subtitlingStyling.fontSize, - subtitleBuilder: (v) => '$v', - labelText: t.subtitlingStyling.fontSize, - suffixText: '', - min: 10, - max: 80, - ), - SettingColorTile( - pref: SettingsService.subtitleTextColor, - icon: Symbols.format_color_text_rounded, - title: t.subtitlingStyling.textColor, - ), - SettingNumberTile( - pref: SettingsService.subtitlePosition, - icon: Symbols.vertical_align_bottom_rounded, - title: t.subtitlingStyling.position, - subtitleBuilder: _formatPosition, - labelText: t.subtitlingStyling.position, - suffixText: '%', - min: 0, - max: 100, - ), - SettingSwitchTile( - pref: SettingsService.subtitleBold, - icon: Symbols.format_bold_rounded, - title: t.subtitlingStyling.bold, - ), - SettingSwitchTile( - pref: SettingsService.subtitleItalic, - icon: Symbols.format_italic_rounded, - title: t.subtitlingStyling.italic, - ), - - SettingsSectionHeader(t.subtitlingStyling.border), - SettingNumberTile( - pref: SettingsService.subtitleBorderSize, - icon: Symbols.border_style_rounded, - title: t.subtitlingStyling.borderSize, - subtitleBuilder: (v) => '$v', - labelText: t.subtitlingStyling.borderSize, - suffixText: '', - min: 0, - max: 5, - ), - SettingColorTile( - pref: SettingsService.subtitleBorderColor, - icon: Symbols.border_color_rounded, - title: t.subtitlingStyling.borderColor, - ), - - SettingsSectionHeader(t.subtitlingStyling.background), - SettingNumberTile( - pref: SettingsService.subtitleBackgroundOpacity, - icon: Symbols.opacity_rounded, - title: t.subtitlingStyling.backgroundOpacity, - subtitleBuilder: (v) => '$v%', - labelText: t.subtitlingStyling.backgroundOpacity, - suffixText: '%', - min: 0, - max: 100, - ), - SettingColorTile( - pref: SettingsService.subtitleBackgroundColor, - icon: Symbols.format_color_fill_rounded, - title: t.subtitlingStyling.backgroundColor, - ), - const SizedBox(height: 24), - ]), + children: [ + SettingsSectionHeader(t.subtitlingStyling.text), + SettingSelectionTile( + pref: SettingsService.subAssOverride, + icon: Symbols.subtitles_rounded, + title: t.subtitlingStyling.assOverride, + subtitleBuilder: _assOverrideLabel, + options: SubAssOverride.values.map((v) => DialogOption(value: v, title: _assOverrideLabel(v))).toList(), + decode: (v) => v, + encode: (v) => v, ), + SettingNumberTile( + pref: SettingsService.subtitleFontSize, + icon: Symbols.format_size_rounded, + title: t.subtitlingStyling.fontSize, + subtitleBuilder: (v) => '$v', + labelText: t.subtitlingStyling.fontSize, + suffixText: '', + min: 10, + max: 80, + ), + SettingColorTile( + pref: SettingsService.subtitleTextColor, + icon: Symbols.format_color_text_rounded, + title: t.subtitlingStyling.textColor, + ), + SettingNumberTile( + pref: SettingsService.subtitlePosition, + icon: Symbols.vertical_align_bottom_rounded, + title: t.subtitlingStyling.position, + subtitleBuilder: _formatPosition, + labelText: t.subtitlingStyling.position, + suffixText: '%', + min: 0, + max: 100, + ), + SettingSwitchTile( + pref: SettingsService.subtitleBold, + icon: Symbols.format_bold_rounded, + title: t.subtitlingStyling.bold, + ), + SettingSwitchTile( + pref: SettingsService.subtitleItalic, + icon: Symbols.format_italic_rounded, + title: t.subtitlingStyling.italic, + ), + + SettingsSectionHeader(t.subtitlingStyling.border), + SettingNumberTile( + pref: SettingsService.subtitleBorderSize, + icon: Symbols.border_style_rounded, + title: t.subtitlingStyling.borderSize, + subtitleBuilder: (v) => '$v', + labelText: t.subtitlingStyling.borderSize, + suffixText: '', + min: 0, + max: 5, + ), + SettingColorTile( + pref: SettingsService.subtitleBorderColor, + icon: Symbols.border_color_rounded, + title: t.subtitlingStyling.borderColor, + ), + + SettingsSectionHeader(t.subtitlingStyling.background), + SettingNumberTile( + pref: SettingsService.subtitleBackgroundOpacity, + icon: Symbols.opacity_rounded, + title: t.subtitlingStyling.backgroundOpacity, + subtitleBuilder: (v) => '$v%', + labelText: t.subtitlingStyling.backgroundOpacity, + suffixText: '%', + min: 0, + max: 100, + ), + SettingColorTile( + pref: SettingsService.subtitleBackgroundColor, + icon: Symbols.format_color_fill_rounded, + title: t.subtitlingStyling.backgroundColor, + ), + const SizedBox(height: 24), ], ); } diff --git a/lib/screens/settings/tracker_account_settings_body.dart b/lib/screens/settings/tracker_account_settings_body.dart new file mode 100644 index 00000000..ae4bf487 --- /dev/null +++ b/lib/screens/settings/tracker_account_settings_body.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../i18n/strings.g.dart'; +import '../../services/settings_service.dart'; +import '../../services/trackers/tracker_constants.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/setting_tile.dart'; +import '../../widgets/settings_builder.dart'; +import '../../widgets/settings_page.dart'; +import '../../widgets/settings_section.dart'; +import 'tracker_library_filter_screen.dart'; + +class TrackerSettingsToggle { + final Pref pref; + final IconData icon; + final String title; + final String subtitle; + final FutureOr Function(bool)? onAfterWrite; + + const TrackerSettingsToggle({ + required this.pref, + required this.icon, + required this.title, + required this.subtitle, + this.onAfterWrite, + }); +} + +class TrackerAccountSettingsBody extends StatelessWidget { + final Widget title; + final String accountTitle; + final String? accountSubtitle; + final TrackerService service; + final List toggles; + final FutureOr Function() onDisconnect; + + const TrackerAccountSettingsBody({ + super.key, + required this.title, + required this.accountTitle, + this.accountSubtitle, + required this.service, + required this.toggles, + required this.onDisconnect, + }); + + @override + Widget build(BuildContext context) { + return SettingsPage( + title: title, + children: [ + ListTile( + leading: const AppIcon(Symbols.account_circle_rounded, fill: 1), + title: Text(accountTitle), + subtitle: accountSubtitle != null ? Text(accountSubtitle!) : null, + ), + SettingsSectionHeader(t.settings.behavior), + for (final toggle in toggles) + SettingSwitchTile( + pref: toggle.pref, + icon: toggle.icon, + title: toggle.title, + subtitle: toggle.subtitle, + onAfterWrite: toggle.onAfterWrite, + ), + SettingsBuilder( + prefs: [SettingsService.trackerFilterModePref(service), SettingsService.trackerFilterIdsPref(service)], + builder: (context) { + final settings = SettingsService.instanceOrNull!; + return ListTile( + leading: const AppIcon(Symbols.filter_list_rounded, fill: 1), + title: Text(t.trackers.libraryFilter.title), + subtitle: Text(TrackerLibraryFilterScreen.subtitleFor(settings, service)), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => TrackerLibraryFilterScreen(service: service))), + ); + }, + ), + const Divider(height: 32), + ListTile( + leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error), + title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)), + onTap: () => unawaited(Future.sync(onDisconnect)), + ), + const SizedBox(height: 24), + ], + ); + } +} diff --git a/lib/screens/settings/tracker_library_filter_screen.dart b/lib/screens/settings/tracker_library_filter_screen.dart index a90318f1..cca407c0 100644 --- a/lib/screens/settings/tracker_library_filter_screen.dart +++ b/lib/screens/settings/tracker_library_filter_screen.dart @@ -9,9 +9,9 @@ import '../../services/settings_service.dart'; import '../../services/trackers/tracker_constants.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/focusable_list_tile.dart'; -import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; +import '../../widgets/settings_page.dart'; import '../../widgets/settings_section.dart'; /// Per-provider library whitelist/blacklist screen. Toggling a switch @@ -119,10 +119,7 @@ class TrackerLibraryFilterScreen extends StatelessWidget { children.add(const SizedBox(height: 24)); - return FocusedScrollScaffold( - title: title, - slivers: [SliverList(delegate: SliverChildListDelegate(children))], - ); + return SettingsPage(title: title, children: children); }, ); }, diff --git a/lib/screens/settings/tracker_settings_screen.dart b/lib/screens/settings/tracker_settings_screen.dart index f33ab9eb..a267dc21 100644 --- a/lib/screens/settings/tracker_settings_screen.dart +++ b/lib/screens/settings/tracker_settings_screen.dart @@ -12,15 +12,11 @@ import '../../services/trackers/simkl/simkl_tracker.dart'; import '../../services/trackers/tracker_constants.dart'; import '../../services/settings_service.dart'; import '../../utils/dialogs.dart'; -import '../../widgets/app_icon.dart'; import '../../widgets/device_code_dialog.dart'; -import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/oauth_proxy_dialog.dart'; -import '../../widgets/setting_tile.dart'; -import '../../widgets/settings_builder.dart'; -import '../../widgets/settings_section.dart'; +import '../../widgets/settings_page.dart'; +import 'tracker_account_settings_body.dart'; import 'tracker_connect_launcher.dart'; -import 'tracker_library_filter_screen.dart'; Future startMalConnection(BuildContext context) { final account = context.read(); @@ -145,58 +141,27 @@ class TrackerSettingsScreen extends StatelessWidget { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) Navigator.of(context).pop(); }); - return FocusedScrollScaffold( + return SettingsPage.slivers( title: title, slivers: const [SliverFillRemaining(child: SizedBox.shrink())], ); } final username = config.username(account); - return FocusedScrollScaffold( + return TrackerAccountSettingsBody( title: title, - slivers: [ - SliverList( - delegate: SliverChildListDelegate([ - ListTile( - leading: const AppIcon(Symbols.account_circle_rounded, fill: 1), - title: Text(username != null ? t.trackers.connectedAs(username: username) : config.displayName), - ), - SettingsSectionHeader(t.settings.behavior), - SettingSwitchTile( - pref: config.scrobblePref, - icon: Symbols.auto_timer, - title: t.trackers.scrobble, - subtitle: t.trackers.scrobbleDescription, - onAfterWrite: config.onScrobbleChanged, - ), - SettingsBuilder( - prefs: [ - SettingsService.trackerFilterModePref(config.service), - SettingsService.trackerFilterIdsPref(config.service), - ], - builder: (context) { - final settings = SettingsService.instanceOrNull!; - return ListTile( - leading: const AppIcon(Symbols.filter_list_rounded, fill: 1), - title: Text(t.trackers.libraryFilter.title), - subtitle: Text(TrackerLibraryFilterScreen.subtitleFor(settings, config.service)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => TrackerLibraryFilterScreen(service: config.service)), - ), - ); - }, - ), - const Divider(height: 32), - ListTile( - leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error), - title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)), - onTap: () => _disconnect(context, account), - ), - const SizedBox(height: 24), - ]), + accountTitle: username != null ? t.trackers.connectedAs(username: username) : config.displayName, + service: config.service, + toggles: [ + TrackerSettingsToggle( + pref: config.scrobblePref, + icon: Symbols.auto_timer, + title: t.trackers.scrobble, + subtitle: t.trackers.scrobbleDescription, + onAfterWrite: config.onScrobbleChanged, ), ], + onDisconnect: () => _disconnect(context, account), ); }, ); diff --git a/lib/screens/settings/trakt_settings_screen.dart b/lib/screens/settings/trakt_settings_screen.dart index a180446c..92de30d2 100644 --- a/lib/screens/settings/trakt_settings_screen.dart +++ b/lib/screens/settings/trakt_settings_screen.dart @@ -10,14 +10,10 @@ import '../../services/trackers/tracker_constants.dart'; import '../../services/trakt/trakt_scrobble_service.dart'; import '../../services/trakt/trakt_sync_service.dart'; import '../../utils/dialogs.dart'; -import '../../widgets/app_icon.dart'; import '../../widgets/device_code_dialog.dart'; -import '../../widgets/focused_scroll_scaffold.dart'; -import '../../widgets/setting_tile.dart'; -import '../../widgets/settings_builder.dart'; -import '../../widgets/settings_section.dart'; +import '../../widgets/settings_page.dart'; +import 'tracker_account_settings_body.dart'; import 'tracker_connect_launcher.dart'; -import 'tracker_library_filter_screen.dart'; Future startTraktConnection(BuildContext context) { final account = context.read(); @@ -61,68 +57,35 @@ class TraktSettingsScreen extends StatelessWidget { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) Navigator.of(context).pop(); }); - return FocusedScrollScaffold( + return SettingsPage.slivers( title: Text(t.trakt.title), slivers: const [SliverFillRemaining(child: SizedBox.shrink())], ); } final username = account.username; - return FocusedScrollScaffold( + return TrackerAccountSettingsBody( title: Text(t.trakt.title), - slivers: [ - SliverList( - delegate: SliverChildListDelegate([ - ListTile( - leading: const AppIcon(Symbols.account_circle_rounded, fill: 1), - title: Text(username != null ? t.trakt.connectedAs(username: username) : t.trakt.connected), - subtitle: Text(t.trakt.connected), - ), - SettingsSectionHeader(t.settings.behavior), - SettingSwitchTile( - pref: SettingsService.enableTraktScrobble, - icon: Symbols.auto_timer, - title: t.trakt.scrobble, - subtitle: t.trakt.scrobbleDescription, - onAfterWrite: TraktScrobbleService.instance.setEnabled, - ), - SettingSwitchTile( - pref: SettingsService.enableTraktWatchedSync, - icon: Symbols.check_circle_rounded, - title: t.trakt.watchedSync, - subtitle: t.trakt.watchedSyncDescription, - onAfterWrite: TraktSyncService.instance.setEnabled, - ), - SettingsBuilder( - prefs: [ - SettingsService.trackerFilterModePref(TrackerService.trakt), - SettingsService.trackerFilterIdsPref(TrackerService.trakt), - ], - builder: (context) { - final settings = SettingsService.instanceOrNull!; - return ListTile( - leading: const AppIcon(Symbols.filter_list_rounded, fill: 1), - title: Text(t.trackers.libraryFilter.title), - subtitle: Text(TrackerLibraryFilterScreen.subtitleFor(settings, TrackerService.trakt)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const TrackerLibraryFilterScreen(service: TrackerService.trakt), - ), - ), - ); - }, - ), - const Divider(height: 32), - ListTile( - leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error), - title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)), - onTap: () => _disconnect(context, account), - ), - const SizedBox(height: 24), - ]), + accountTitle: username != null ? t.trakt.connectedAs(username: username) : t.trakt.connected, + accountSubtitle: t.trakt.connected, + service: TrackerService.trakt, + toggles: [ + TrackerSettingsToggle( + pref: SettingsService.enableTraktScrobble, + icon: Symbols.auto_timer, + title: t.trakt.scrobble, + subtitle: t.trakt.scrobbleDescription, + onAfterWrite: TraktScrobbleService.instance.setEnabled, + ), + TrackerSettingsToggle( + pref: SettingsService.enableTraktWatchedSync, + icon: Symbols.check_circle_rounded, + title: t.trakt.watchedSync, + subtitle: t.trakt.watchedSyncDescription, + onAfterWrite: TraktSyncService.instance.setEnabled, ), ], + onDisconnect: () => _disconnect(context, account), ); }, ); diff --git a/lib/widgets/desktop_app_bar.dart b/lib/widgets/desktop_app_bar.dart index ddb85a9b..95bb7ab9 100644 --- a/lib/widgets/desktop_app_bar.dart +++ b/lib/widgets/desktop_app_bar.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; -import 'package:material_symbols_icons/symbols.dart'; import '../utils/desktop_window_padding.dart'; import '../services/fullscreen_state_manager.dart'; import 'app_bar_back_button.dart'; @@ -54,10 +52,10 @@ class DesktopAppBarSections { final canPop = parentRoute?.canPop ?? false; if (canPop) { - effectiveLeading = IconButton( - icon: const AppIcon(Symbols.arrow_back_rounded, fill: 1), + effectiveLeading = AppBarBackButton( + style: BackButtonStyle.plain, onPressed: () => Navigator.of(context).pop(), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, + semanticLabel: MaterialLocalizations.of(context).backButtonTooltip, ); } } diff --git a/lib/widgets/settings_page.dart b/lib/widgets/settings_page.dart new file mode 100644 index 00000000..c3b4f59d --- /dev/null +++ b/lib/widgets/settings_page.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import 'focused_scroll_scaffold.dart'; + +/// Standard scaffold for settings pages made of ordinary list rows. +class SettingsPage extends StatelessWidget { + final Widget title; + final List? children; + final List? slivers; + final List? actions; + final EdgeInsetsGeometry? padding; + final bool pinned; + final bool automaticallyImplyLeading; + final VoidCallback? onBackPressed; + + const SettingsPage({ + super.key, + required this.title, + required List this.children, + this.actions, + this.padding, + this.pinned = true, + this.automaticallyImplyLeading = true, + this.onBackPressed, + }) : slivers = null; + + const SettingsPage.slivers({ + super.key, + required this.title, + required List this.slivers, + this.actions, + this.pinned = true, + this.automaticallyImplyLeading = true, + this.onBackPressed, + }) : children = null, + padding = null; + + @override + Widget build(BuildContext context) { + final pageSlivers = slivers ?? [_buildListSliver()]; + return FocusedScrollScaffold( + title: title, + actions: actions, + pinned: pinned, + automaticallyImplyLeading: automaticallyImplyLeading, + onBackPressed: onBackPressed, + slivers: pageSlivers, + ); + } + + Widget _buildListSliver() { + final list = SliverList(delegate: SliverChildListDelegate(children!)); + final pagePadding = padding; + if (pagePadding == null) return list; + return SliverPadding(padding: pagePadding, sliver: list); + } +} diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 5fbd2920..d4d37c34 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -16,6 +16,7 @@ import '../../../utils/provider_extensions.dart'; import '../../../utils/scroll_utils.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; +import '../widgets/media_selector_thumbnail.dart'; import 'base_video_control_sheet.dart'; import '../../optimized_media_image.dart'; @@ -96,37 +97,21 @@ class _ChapterSheetState extends State { return FocusableListTile( key: index == 0 ? _initialScroll.firstItemKey : null, leading: chapter.thumb != null - ? SizedBox( + ? MediaSelectorThumbnail( width: 60, height: 34, - child: Stack( - children: [ - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(4)), - child: OptimizedMediaImage.thumb( - client: _tryGetClientForChapters(context), - imagePath: chapter.thumb, - localFilePath: localThumbPath, - width: 60, - height: 34, - fit: BoxFit.cover, - errorWidget: (context, url, error) => - const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), - ), - ), - if (isCurrentChapter) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(4)), - border: Border.fromBorderSide( - BorderSide(color: Theme.of(context).colorScheme.primary, width: 2), - ), - ), - ), - ), - ], + thumbnail: OptimizedMediaImage.thumb( + client: _tryGetClientForChapters(context), + imagePath: chapter.thumb, + localFilePath: localThumbPath, + width: 60, + height: 34, + fit: BoxFit.cover, + errorWidget: (context, url, error) => + const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), ), + isCurrent: isCurrentChapter, + borderColor: Theme.of(context).colorScheme.primary, ) : null, title: Text( diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart index 47d1746a..4564f53d 100644 --- a/lib/widgets/video_controls/sheets/queue_sheet.dart +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -11,6 +11,7 @@ import '../../../utils/provider_extensions.dart'; import '../../../utils/scroll_utils.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; +import '../widgets/media_selector_thumbnail.dart'; import 'base_video_control_sheet.dart'; import '../../optimized_media_image.dart'; @@ -102,34 +103,20 @@ class _QueueSheetState extends State { // Try to get client for thumbnails, may fail in offline mode final client = context.tryGetMediaClientForServer(item.serverId); - return SizedBox( + return MediaSelectorThumbnail( width: _kThumbWidth, height: _kThumbHeight, - child: Stack( - children: [ - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(4)), - child: OptimizedMediaImage.thumb( - client: client, - imagePath: item.thumbPath, - width: _kThumbWidth, - height: _kThumbHeight, - fit: BoxFit.cover, - errorWidget: (context, url, error) => - AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: _kThumbHeight), - ), - ), - if (isCurrent) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(4)), - border: Border.fromBorderSide(BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)), - ), - ), - ), - ], + thumbnail: OptimizedMediaImage.thumb( + client: client, + imagePath: item.thumbPath, + width: _kThumbWidth, + height: _kThumbHeight, + fit: BoxFit.cover, + errorWidget: (context, url, error) => + AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: _kThumbHeight), ), + isCurrent: isCurrent, + borderColor: Theme.of(context).colorScheme.primary, ); } diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index b0b29cde..8f7fe97c 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -19,6 +19,7 @@ import '../../../utils/player_utils.dart'; import '../../../utils/provider_extensions.dart'; import '../../app_icon.dart'; import '../../optimized_media_image.dart'; +import 'media_selector_thumbnail.dart'; /// Horizontal scrollable strip of chapter/queue items shown on swipe-up. class ContentStrip extends StatefulWidget { @@ -518,33 +519,13 @@ class ContentStripState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( + MediaSelectorThumbnail( width: itemWidth, height: thumbHeight, - child: Stack( - children: [ - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(6)), - child: - thumbnail ?? - Container( - color: Colors.white10, - child: const Center( - child: AppIcon(Symbols.movie_rounded, fill: 1, color: Colors.white38, size: 28), - ), - ), - ), - if (isCurrent) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(6)), - border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)), - ), - ), - ), - ], - ), + thumbnail: thumbnail, + isCurrent: isCurrent, + borderColor: Colors.white, + radius: 6, ), const SizedBox(height: 4), Text( diff --git a/lib/widgets/video_controls/widgets/media_selector_thumbnail.dart b/lib/widgets/video_controls/widgets/media_selector_thumbnail.dart new file mode 100644 index 00000000..401b878d --- /dev/null +++ b/lib/widgets/video_controls/widgets/media_selector_thumbnail.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../app_icon.dart'; + +class MediaSelectorThumbnail extends StatelessWidget { + final double width; + final double height; + final Widget? thumbnail; + final bool isCurrent; + final double radius; + final Color borderColor; + final Color fallbackBackgroundColor; + final Color fallbackIconColor; + final double fallbackIconSize; + final IconData fallbackIcon; + + const MediaSelectorThumbnail({ + super.key, + required this.width, + required this.height, + required this.thumbnail, + required this.isCurrent, + required this.borderColor, + this.radius = 4, + this.fallbackBackgroundColor = Colors.white10, + this.fallbackIconColor = Colors.white38, + this.fallbackIconSize = 28, + this.fallbackIcon = Symbols.movie_rounded, + }); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.all(Radius.circular(radius)); + return SizedBox( + width: width, + height: height, + child: Stack( + children: [ + ClipRRect( + borderRadius: borderRadius, + child: + thumbnail ?? + Container( + color: fallbackBackgroundColor, + child: Center( + child: AppIcon(fallbackIcon, fill: 1, color: fallbackIconColor, size: fallbackIconSize), + ), + ), + ), + if (isCurrent) + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.fromBorderSide(BorderSide(color: borderColor, width: 2)), + ), + ), + ), + ], + ), + ); + } +}