import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../i18n/app_locale_utils.dart'; import '../../i18n/strings.g.dart'; import '../../providers/multi_server_provider.dart'; import '../../providers/theme_provider.dart'; import '../../profiles/active_profile_provider.dart'; import '../../navigation/navigation_tabs.dart'; import '../../services/settings_service.dart' hide ThemeMode; import '../../services/settings_service.dart' as settings show ThemeMode; import '../../focus/focusable_slider.dart'; import '../../services/device_performance.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/focusable_list_tile.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'; class AppearanceSettingsScreen extends StatelessWidget { const AppearanceSettingsScreen({super.key}); @override Widget build(BuildContext context) { // Watched at build level so the tile can be excluded with a plain `if` — // a child that renders SizedBox.shrink() would corrupt the group corners. final hasMultipleProfiles = context.watch().hasMultipleProfiles; return SettingsPage( title: Text(t.settings.appearance), children: [ SettingsGroup( title: t.settings.display, children: [ _themeSelector(), _languageSelector(context), _densitySelector(), _viewModeSelector(), _episodePosterModeSelector(), if (PlatformDetector.isTV()) SettingSwitchTile( pref: SettingsService.tvFullCardLayout, icon: Symbols.image_rounded, title: t.settings.tvFullCardLayout, subtitle: t.settings.tvFullCardLayoutDescription, ), if (PlatformDetector.isTV()) SettingSwitchTile( pref: SettingsService.tvCornerSpotlightBackdrop, icon: Symbols.picture_in_picture_alt_rounded, title: t.settings.tvCornerSpotlightBackdrop, subtitle: t.settings.tvCornerSpotlightBackdropDescription, ), if (PlatformDetector.isTV()) SettingSwitchTile( pref: SettingsService.focusGlow, icon: Symbols.lightbulb_rounded, title: t.settings.focusGlow, subtitle: t.settings.focusGlowDescription, ), if (Platform.isAndroid) _visualEffectsSelector(context), SettingSwitchTile( pref: SettingsService.showEpisodeNumberOnCards, icon: Symbols.tag_rounded, title: t.settings.showEpisodeNumberOnCards, subtitle: t.settings.showEpisodeNumberOnCardsDescription, ), if (!PlatformDetector.isTV()) SettingSwitchTile( pref: SettingsService.showSeasonPostersOnTabs, icon: Symbols.image_rounded, title: t.settings.showSeasonPostersOnTabs, subtitle: t.settings.showSeasonPostersOnTabsDescription, ), ], ), SettingsGroup( title: t.settings.homeScreen, children: [ if (!PlatformDetector.isTV()) SettingSwitchTile( pref: SettingsService.showHeroSection, icon: Symbols.featured_play_list_rounded, title: t.settings.showHeroSection, subtitle: t.settings.showHeroSectionDescription, ), _continueWatchingActionSelector(), 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, ), ], ), SettingsGroup( title: t.settings.navigation, children: [ _startupSectionSelector(), if (Platform.isAndroid || PlatformDetector.isDesktopOS()) 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 (PlatformDetector.isDesktopOS()) SettingsGroup( title: t.settings.window, children: [ SettingSwitchTile( pref: SettingsService.startInFullscreen, icon: Symbols.fullscreen_rounded, title: t.settings.startInFullscreen, subtitle: t.settings.startInFullscreenDescription, ), SettingSwitchTile( pref: SettingsService.exitFullscreenOnPlayerClose, icon: Symbols.fullscreen_exit_rounded, title: t.settings.exitFullscreenOnPlayerClose, subtitle: t.settings.exitFullscreenOnPlayerCloseDescription, ), ], ), SettingsGroup( title: t.settings.content, children: [ 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, ), _episodeActionSelector(), if (hasMultipleProfiles) SettingSwitchTile( pref: SettingsService.requireProfileSelectionOnOpen, icon: Symbols.person_rounded, title: t.settings.requireProfileSelectionOnOpen, subtitle: t.settings.requireProfileSelectionOnOpenDescription, ), SettingSwitchTile( pref: SettingsService.autoHidePerformanceOverlay, icon: Symbols.speed_rounded, title: t.settings.autoHidePerformanceOverlay, subtitle: t.settings.autoHidePerformanceOverlayDescription, ), ], ), const SizedBox(height: 24), ], ); } // Writes the pref directly; ThemeProvider listens to the pref's listenable // and applies the change live. The Consumer only feeds the dynamic icon. Widget _themeSelector() { return Consumer( builder: (context, themeProvider, _) { return SettingSelectionTile( pref: SettingsService.themeMode, icon: themeProvider.themeModeIcon, title: t.settings.theme, subtitleBuilder: themeModeLabel, options: settings.ThemeMode.values.map((m) => DialogOption(value: m, title: themeModeLabel(m))).toList(), ); }, ); } Widget _languageSelector(BuildContext context) { return FocusableListTile( leading: const AppIcon(Symbols.language_rounded, fill: 1), title: Text(t.settings.language), subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () async { final value = await showSelectionDialog( context: context, title: t.settings.language, options: AppLocale.values .map((locale) => DialogOption(value: locale, title: _getLanguageDisplayName(locale))) .toList(), currentValue: LocaleSettings.currentLocale, ); if (value != null) { await SettingsService.instance.write(SettingsService.appLocale, value); unawaited(LocaleSettings.setLocale(value)); if (context.mounted) { context.read().serverManager.updatePlexLanguage(value.plexLanguageCode); } if (context.mounted) _restartApp(context); } }, ); } // Same label-row-plus-control layout as SegmentedSetting so slider and // button-group tiles read as one family inside a SettingsGroup. Widget _densitySelector() { return SettingValueBuilder( pref: SettingsService.libraryDensity, builder: (context, density, _) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: .start, children: [ Row( children: [ const AppIcon(Symbols.grid_view_rounded, fill: 1), const SizedBox(width: 16), Text(t.settings.libraryDensity, style: settingsOptionTitleStyle(context)), ], ), const SizedBox(height: 12), FocusableSlider( value: density.toDouble(), min: 1, max: 5, divisions: 4, onChanged: (v) => SettingsService.instance.write(SettingsService.libraryDensity, v.round()), ), Row( mainAxisAlignment: .spaceBetween, children: [ Text(t.settings.compact, style: theme.textTheme.bodySmall), Text(t.settings.comfortable, style: theme.textTheme.bodySmall), ], ), ], ), ); }, ); } Widget _viewModeSelector() => SettingSegmentedTile( pref: SettingsService.viewMode, icon: Symbols.view_list_rounded, title: t.settings.viewMode, segments: [ ButtonSegment(value: ViewMode.grid, label: Text(t.settings.gridView)), ButtonSegment(value: ViewMode.list, label: Text(t.settings.listView)), ], ); Widget _episodePosterModeSelector() => SettingSegmentedTile( pref: SettingsService.episodePosterMode, icon: Symbols.image_rounded, title: t.settings.episodePosterMode, segments: [ ButtonSegment(value: EpisodePosterMode.seriesPoster, label: Text(t.settings.seriesPoster)), ButtonSegment(value: EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)), ButtonSegment(value: EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)), ], ); Widget _continueWatchingActionSelector() => SettingSegmentedTile( pref: SettingsService.continueWatchingAction, icon: Symbols.play_circle_rounded, title: t.settings.continueWatchingAction, segments: [ ButtonSegment(value: ContinueWatchingAction.play, label: Text(t.settings.continueWatchingPlay)), ButtonSegment(value: ContinueWatchingAction.details, label: Text(t.settings.continueWatchingDetails)), ], ); Widget _episodeActionSelector() => SettingSegmentedTile( pref: SettingsService.episodeAction, icon: Symbols.tv_rounded, title: t.settings.episodeAction, segments: [ ButtonSegment(value: EpisodeAction.play, label: Text(t.settings.episodePlay)), ButtonSegment(value: EpisodeAction.details, label: Text(t.settings.episodeDetails)), ], ); // Sections offered as a startup destination, in display order. Live TV is // always listed; if no server provides it, startup falls back to Home. static const _startupSectionOptions = [ NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.liveTv, NavigationTabId.search, ]; String _startupSectionLabel(NavigationTabId id) => allNavigationTabs.firstWhere((t) => t.id == id).getLabel(); Widget _startupSectionSelector() => SettingSelectionTile( pref: SettingsService.startupSection, icon: Symbols.start_rounded, title: t.settings.startupSection, subtitleBuilder: _startupSectionLabel, options: _startupSectionOptions.map((id) => DialogOption(value: id, title: _startupSectionLabel(id))).toList(), ); String _visualEffectsLabel(VisualEffectsSetting value) => switch (value) { VisualEffectsSetting.auto => t.settings.visualEffectsAuto, VisualEffectsSetting.full => t.settings.visualEffectsFull, VisualEffectsSetting.reduced => t.settings.visualEffectsReduced, }; Widget _visualEffectsSelector(BuildContext context) => SettingSelectionTile( pref: SettingsService.visualEffects, icon: Symbols.animation_rounded, title: t.settings.visualEffects, subtitleBuilder: _visualEffectsLabel, options: [ DialogOption( value: VisualEffectsSetting.auto, title: t.settings.visualEffectsAuto, subtitle: t.settings.visualEffectsAutoDescription, ), DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull), DialogOption( value: VisualEffectsSetting.reduced, title: t.settings.visualEffectsReduced, subtitle: t.settings.visualEffectsReducedDescription, ), ], onAfterWrite: (value) { DevicePerformance.setOverrideSync(value); _restartApp(context); }, ); String _getLanguageDisplayName(AppLocale locale) { switch (locale) { case AppLocale.en: return 'English'; case AppLocale.sv: return 'Svenska'; case AppLocale.fr: return 'Français'; case AppLocale.it: return 'Italiano'; case AppLocale.nl: return 'Nederlands'; case AppLocale.de: return 'Deutsch'; case AppLocale.hu: return 'Magyar'; case AppLocale.zh: return '简体中文'; case AppLocale.zhHant: return '繁體中文'; case AppLocale.ko: return '한국어'; case AppLocale.es: return 'Español'; case AppLocale.pt: return 'Português'; case AppLocale.ja: return '日本語'; case AppLocale.ru: return 'Русский'; case AppLocale.pl: return 'Polski'; case AppLocale.da: return 'Dansk'; case AppLocale.nb: return 'Norsk bokmål'; case AppLocale.bg: return 'Български'; } } void _restartApp(BuildContext context) { Navigator.of(context, rootNavigator: true).pushNamedAndRemoveUntil('/', (route) => false); } }