Files
plezy/lib/screens/settings/appearance_settings_screen.dart
T
edde746 9d13584c00 feat(settings): add appearance toggle to hide the Explore tab
The Explore tab appears for every Plex-backed profile because the Plex
Discover catalog source connects implicitly, with no way to opt out
short of a Jellyfin-only profile. Add a Show Explore Tab switch to
Appearance > Navigation that hides the tab in the bottom navigation and
side rail. UI-only: catalog sources stay connected so watchlist
surfaces keep working while the tab is hidden.

close #1844
2026-08-09 17:14:47 +02:00

482 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/catalog_sources_provider.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 tiles can be excluded with a plain `if` —
// a child that renders SizedBox.shrink() would corrupt the group corners.
final hasMultipleProfiles = context.watch<ActiveProfileProvider>().hasMultipleProfiles;
// Nullable watch: hosts without the profile session scope (tests) simply
// never show the Explore toggle, mirroring the tab's own visibility.
final hasExplore = context.watch<CatalogSourcesProvider?>()?.hasAnySource ?? false;
return SettingsPage(
title: Text(t.settings.appearance),
children: [
SettingsGroup(
title: t.settings.display,
children: [
_themeSelector(),
_languageSelector(context),
_densitySelector(),
if (PlatformDetector.isAutomotive()) _displayScaleSelector(),
_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 (hasExplore)
SettingSwitchTile(
pref: SettingsService.showExploreTab,
icon: Symbols.explore_rounded,
title: t.settings.showExploreTab,
subtitle: t.settings.showExploreTabDescription,
),
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<ThemeProvider>(
builder: (context, themeProvider, _) {
return SettingSelectionTile<settings.ThemeMode>(
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<AppLocale>(
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<MultiServerProvider>().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<int>(
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 _displayScaleSelector() {
return SettingValueBuilder<double>(
pref: SettingsService.automotiveUiScale,
builder: (context, scale, _) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: .start,
children: [
Row(
children: [
const AppIcon(Symbols.format_size_rounded, fill: 1),
const SizedBox(width: 16),
Text(t.settings.displayScale, style: settingsOptionTitleStyle(context)),
const Spacer(),
Text('${scale.toStringAsFixed(2)}×', style: Theme.of(context).textTheme.bodyMedium),
],
),
const SizedBox(height: 12),
FocusableSlider(
value: scale,
min: AutomotiveUiScale.min,
max: AutomotiveUiScale.max,
divisions: 20,
onChanged: (value) => SettingsService.instance.write(SettingsService.automotiveUiScale, value),
),
],
),
);
},
);
}
Widget _viewModeSelector() => SettingSegmentedTile<ViewMode>(
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<EpisodePosterMode>(
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<ContinueWatchingAction>(
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<EpisodeAction>(
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<NavigationTabId>(
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<VisualEffectsSetting>(
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 'Български';
case AppLocale.tr:
return 'Türkçe';
case AppLocale.az:
return 'Azərbaycanca';
case AppLocale.kk:
return 'Қазақша';
case AppLocale.uz:
return 'Oʻzbekcha';
}
}
void _restartApp(BuildContext context) {
Navigator.of(context, rootNavigator: true).pushNamedAndRemoveUntil('/', (route) => false);
}
}