From 130e0d6f406b0debab31517c19915f4c89f92d38 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 2 May 2026 08:44:27 +0200 Subject: [PATCH] refactor: streamline settings bindings --- lib/main.dart | 2 - lib/mixins/settings_effect_mixin.dart | 49 + lib/providers/settings_provider.dart | 83 -- lib/providers/shader_provider.dart | 63 +- lib/providers/theme_provider.dart | 50 +- lib/screens/discover_screen.dart | 38 +- lib/screens/downloads/downloads_screen.dart | 59 +- .../focusable_detail_screen_mixin.dart | 37 +- lib/screens/hub_detail_screen.dart | 21 +- .../libraries/adaptive_media_grid.dart | 14 +- lib/screens/libraries/folder_tree_item.dart | 23 +- lib/screens/libraries/libraries_screen.dart | 15 +- .../libraries/tabs/library_browse_tab.dart | 30 +- lib/screens/livetv/live_tv_screen.dart | 5 +- lib/screens/livetv/tabs/whats_on_tab.dart | 17 +- lib/screens/main_screen.dart | 15 +- lib/screens/media_detail_screen.dart | 27 +- lib/screens/settings/about_screen.dart | 134 ++- .../settings/appearance_settings_screen.dart | 451 ++++------ .../settings/external_player_screen.dart | 329 +++---- .../settings/keyboard_shortcuts_screen.dart | 138 ++- lib/screens/settings/licenses_screen.dart | 100 +-- lib/screens/settings/mpv_config_screen.dart | 189 ++-- .../settings/playback_settings_screen.dart | 847 ++++++------------ lib/screens/settings/settings_screen.dart | 277 ++---- lib/screens/settings/settings_utils.dart | 102 +++ .../settings/subtitle_styling_screen.dart | 376 ++------ .../tracker_library_filter_screen.dart | 189 ++-- .../settings/tracker_settings_loader.dart | 26 - .../settings/tracker_settings_screen.dart | 114 +-- .../settings/trakt_settings_screen.dart | 90 +- lib/screens/video_player_screen.dart | 10 +- .../base_shared_preferences_service.dart | 37 +- lib/services/keyboard_shortcuts_service.dart | 103 ++- lib/services/settings_service.dart | 167 ++-- lib/widgets/episode_card.dart | 14 +- lib/widgets/hub_section.dart | 216 ++--- lib/widgets/media_card.dart | 40 +- lib/widgets/setting_tile.dart | 346 +++++++ lib/widgets/settings_builder.dart | 52 ++ lib/widgets/side_navigation_rail.dart | 30 +- .../video_controls/parts/track_controls.dart | 54 +- .../video_controls/parts/visibility.dart | 27 +- .../sheets/video_settings_sheet.dart | 195 ++-- .../video_controls/video_controls.dart | 66 +- .../widgets/volume_control.dart | 135 ++- test/providers/settings_provider_test.dart | 162 ---- test/services/settings_service_test.dart | 43 + 48 files changed, 2535 insertions(+), 3072 deletions(-) create mode 100644 lib/mixins/settings_effect_mixin.dart delete mode 100644 lib/providers/settings_provider.dart delete mode 100644 lib/screens/settings/tracker_settings_loader.dart create mode 100644 lib/widgets/setting_tile.dart create mode 100644 lib/widgets/settings_builder.dart delete mode 100644 test/providers/settings_provider_test.dart diff --git a/lib/main.dart b/lib/main.dart index 947a81ec..c5981b48 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,7 +40,6 @@ import 'providers/trackers_provider.dart'; import 'providers/user_profile_provider.dart'; import 'providers/multi_server_provider.dart'; import 'providers/theme_provider.dart'; -import 'providers/settings_provider.dart'; import 'providers/hidden_libraries_provider.dart'; import 'providers/libraries_provider.dart'; import 'providers/playback_state_provider.dart'; @@ -793,7 +792,6 @@ class _MainAppState extends State with WidgetsBindingObserver { // session scoping. Hydrated and rebound by `_TrackerProfileBootstrap`. ChangeNotifierProvider(create: (context) => TraktAccountProvider()), ChangeNotifierProvider(create: (context) => TrackersProvider()), - ChangeNotifierProvider(create: (context) => SettingsProvider(), lazy: true), ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true), ChangeNotifierProvider(create: (context) => LibrariesProvider()), ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), diff --git a/lib/mixins/settings_effect_mixin.dart b/lib/mixins/settings_effect_mixin.dart new file mode 100644 index 00000000..34d0b7cb --- /dev/null +++ b/lib/mixins/settings_effect_mixin.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; + +import '../services/settings_service.dart'; + +/// Wires up settings → side-effect bindings without manual addListener/dispose. +/// +/// In `initState`: +/// bindEffect(SettingsService.rotationLocked, _applyRotation); +/// bindEffect(SettingsService.audioSyncOffset, (v) => player.setAudioDelay(v)); +/// +/// The callback fires immediately with the current value (unless +/// `fireImmediately: false`) so apply-on-init wiring stays in one place, and +/// then on every subsequent write — no `didChangeAppLifecycleState` reload. +mixin SettingsEffectMixin on State { + final List _settingsEffectDisposers = []; + + /// Subscribe to changes of [pref] and run [effect]. Auto-disposed in [dispose]. + void bindEffect(Pref pref, void Function(V value) effect, {bool fireImmediately = true}) { + final notifier = SettingsService.instanceOrNull!.listenable(pref); + void listener() => effect(notifier.value); + notifier.addListener(listener); + _settingsEffectDisposers.add(() => notifier.removeListener(listener)); + if (fireImmediately) effect(notifier.value); + } + + /// Rebuild this widget when any of [prefs] changes. Use for state classes + /// that synthesize multiple settings into derived getters and need their + /// build to refresh on any change. Equivalent to wrapping the widget tree + /// in a [SettingsBuilder], but lets you keep raw `setState`-style state too. + void bindRebuild(List> prefs) { + final svc = SettingsService.instanceOrNull!; + final merged = Listenable.merge(prefs.map(svc.listenableOf).toList(growable: false)); + void listener() { + if (mounted) setState(() {}); + } + + merged.addListener(listener); + _settingsEffectDisposers.add(() => merged.removeListener(listener)); + } + + @override + void dispose() { + for (final d in _settingsEffectDisposers) { + d(); + } + _settingsEffectDisposers.clear(); + super.dispose(); + } +} diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart deleted file mode 100644 index bca797f9..00000000 --- a/lib/providers/settings_provider.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/material.dart'; -import '../mixins/disposable_change_notifier_mixin.dart'; -import '../models/transcode_quality_preset.dart'; -import '../services/settings_service.dart'; - -class SettingsProvider extends ChangeNotifier with DisposableChangeNotifierMixin { - SettingsService? _settingsService; - bool _isInitialized = false; - Future? _initFuture; - - SettingsProvider() { - _initFuture = _initializeSettings(); - } - - Future ensureInitialized() => _initFuture ?? _initializeSettings(); - - bool get isReady => _isInitialized; - bool get isInitialized => _isInitialized; - Future get ready => _initFuture ?? Future.value(); - - Future _initializeSettings() async { - if (_isInitialized) return; - _settingsService = await SettingsService.getInstance(); - _isInitialized = true; - safeNotifyListeners(); - } - - /// Re-read settings after an external mutation (import, reset). The provider - /// no longer caches values, so this just re-fetches the service instance and - /// notifies listeners. - Future reload() async { - _settingsService = await SettingsService.getInstance(); - _isInitialized = true; - safeNotifyListeners(); - } - - T _read(Pref pref, T fallback) => _isInitialized ? _settingsService!.read(pref) : fallback; - - Future _set(Pref pref, T value) async { - if (!_isInitialized) await _initializeSettings(); - if (_settingsService!.read(pref) == value) return; - await _settingsService!.write(pref, value); - safeNotifyListeners(); - } - - int get libraryDensity => _read(SettingsService.libraryDensity, LibraryDensity.defaultValue); - ViewMode get viewMode => _read(SettingsService.viewMode, ViewMode.grid); - EpisodePosterMode get episodePosterMode => - _read(SettingsService.episodePosterMode, EpisodePosterMode.episodeThumbnail); - bool get showHeroSection => _read(SettingsService.showHeroSection, true); - bool get useGlobalHubs => _read(SettingsService.useGlobalHubs, true); - bool get showServerNameOnHubs => _read(SettingsService.showServerNameOnHubs, false); - bool get groupLibrariesByServer => _read(SettingsService.groupLibrariesByServer, true); - bool get alwaysKeepSidebarOpen => _read(SettingsService.alwaysKeepSidebarOpen, false); - bool get showUnwatchedCount => _read(SettingsService.showUnwatchedCount, true); - bool get showEpisodeNumberOnCards => _read(SettingsService.showEpisodeNumberOnCards, true); - bool get showSeasonPostersOnTabs => _read(SettingsService.showSeasonPostersOnTabs, false); - bool get hideSpoilers => _read(SettingsService.hideSpoilers, false); - bool get showNavBarLabels => _read(SettingsService.showNavBarLabels, true); - bool get liveTvDefaultFavorites => _read(SettingsService.liveTvDefaultFavorites, false); - bool get autoHidePerformanceOverlay => _read(SettingsService.autoHidePerformanceOverlay, true); - TranscodeQualityPreset get defaultQualityPreset => - TranscodeQualityPreset.fromStorage(_read(SettingsService.defaultQualityPreset, 'original')); - - Future setLibraryDensity(int density) => - _set(SettingsService.libraryDensity, density.clamp(LibraryDensity.min, LibraryDensity.max)); - Future setViewMode(ViewMode mode) => _set(SettingsService.viewMode, mode); - Future setEpisodePosterMode(EpisodePosterMode mode) => _set(SettingsService.episodePosterMode, mode); - Future setShowHeroSection(bool value) => _set(SettingsService.showHeroSection, value); - Future setUseGlobalHubs(bool value) => _set(SettingsService.useGlobalHubs, value); - Future setShowServerNameOnHubs(bool value) => _set(SettingsService.showServerNameOnHubs, value); - Future setGroupLibrariesByServer(bool value) => _set(SettingsService.groupLibrariesByServer, value); - Future setAlwaysKeepSidebarOpen(bool value) => _set(SettingsService.alwaysKeepSidebarOpen, value); - Future setShowUnwatchedCount(bool value) => _set(SettingsService.showUnwatchedCount, value); - Future setShowEpisodeNumberOnCards(bool value) => _set(SettingsService.showEpisodeNumberOnCards, value); - Future setShowSeasonPostersOnTabs(bool value) => _set(SettingsService.showSeasonPostersOnTabs, value); - Future setHideSpoilers(bool value) => _set(SettingsService.hideSpoilers, value); - Future setShowNavBarLabels(bool value) => _set(SettingsService.showNavBarLabels, value); - Future setLiveTvDefaultFavorites(bool value) => _set(SettingsService.liveTvDefaultFavorites, value); - Future setAutoHidePerformanceOverlay(bool value) => _set(SettingsService.autoHidePerformanceOverlay, value); - Future setDefaultQualityPreset(TranscodeQualityPreset preset) => - _set(SettingsService.defaultQualityPreset, preset.storageKey); -} diff --git a/lib/providers/shader_provider.dart b/lib/providers/shader_provider.dart index 798b8ed8..2348073d 100644 --- a/lib/providers/shader_provider.dart +++ b/lib/providers/shader_provider.dart @@ -9,7 +9,9 @@ import '../services/shader_asset_loader.dart'; /// /// Persists the selected shader preset so it is restored across sessions. class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin { - late SettingsService _settingsService; + SettingsService? _settingsService; + ValueNotifier? _savedPresetListenable; + ValueNotifier>>? _customPresetsListenable; ShaderPreset _savedPreset = ShaderPreset.none; ShaderPreset _currentPreset = ShaderPreset.none; @@ -21,13 +23,31 @@ class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin { } Future _initialize() async { - _settingsService = await SettingsService.getInstance(); + final service = await SettingsService.getInstance(); + if (_settingsService == service && _savedPresetListenable != null && _customPresetsListenable != null) { + _syncFromSettings(); + return; + } - // Load custom presets from storage - final customData = _settingsService.read(SettingsService.customShaderPresets); - _customPresets = customData.map((json) => ShaderPreset.fromJson(json)).toList(); + _savedPresetListenable?.removeListener(_onSettingsChanged); + _customPresetsListenable?.removeListener(_onSettingsChanged); + _settingsService = service; + _savedPresetListenable = service.listenable(SettingsService.globalShaderPreset)..addListener(_onSettingsChanged); + _customPresetsListenable = service.listenable(SettingsService.customShaderPresets)..addListener(_onSettingsChanged); + _syncFromSettings(); + } - final presetId = _settingsService.read(SettingsService.globalShaderPreset); + void _onSettingsChanged() => _syncFromSettings(); + + void _syncFromSettings() { + final service = _settingsService; + if (service == null) return; + + final customData = service.read(SettingsService.customShaderPresets); + final customPresets = customData.map((json) => ShaderPreset.fromJson(json)).toList(); + _customPresets = customPresets; + + final presetId = service.read(SettingsService.globalShaderPreset); _savedPreset = findPresetById(presetId) ?? ShaderPreset.none; _currentPreset = _savedPreset; @@ -35,6 +55,13 @@ class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin { safeNotifyListeners(); } + @override + void dispose() { + _savedPresetListenable?.removeListener(_onSettingsChanged); + _customPresetsListenable?.removeListener(_onSettingsChanged); + super.dispose(); + } + /// Whether the provider has finished initializing bool get initialized => _initialized; @@ -61,10 +88,13 @@ class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin { /// Apply and persist a shader preset Future setPreset(ShaderPreset preset) async { - _savedPreset = preset; - _currentPreset = preset; - await _settingsService.write(SettingsService.globalShaderPreset, preset.id); - safeNotifyListeners(); + final service = _settingsService ?? await SettingsService.getInstance(); + await service.write(SettingsService.globalShaderPreset, preset.id); + if (_savedPresetListenable == null) { + _savedPreset = preset; + _currentPreset = preset; + safeNotifyListeners(); + } } /// Update the current preset without persisting (e.g. toggling off temporarily) @@ -85,12 +115,12 @@ class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin { _customPresets.add(preset); await _saveCustomPresets(); - safeNotifyListeners(); return preset; } /// Delete a custom shader preset and its file. Future deleteCustomShader(ShaderPreset preset) async { + final wasActive = _currentPreset.id == preset.id || _savedPreset.id == preset.id; if (preset.fileName != null) { await ShaderAssetLoader.deleteCustomShader(preset.fileName!); } @@ -98,18 +128,15 @@ class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin { await _saveCustomPresets(); // Reset to none if the deleted preset was active - if (_currentPreset.id == preset.id || _savedPreset.id == preset.id) { - _savedPreset = ShaderPreset.none; - _currentPreset = ShaderPreset.none; - await _settingsService.write(SettingsService.globalShaderPreset, ShaderPreset.none.id); + if (wasActive) { + await setPreset(ShaderPreset.none); } - - safeNotifyListeners(); } Future _saveCustomPresets() async { + final service = _settingsService ?? await SettingsService.getInstance(); final data = _customPresets.map((p) => p.toJson()).toList(); - await _settingsService.write(SettingsService.customShaderPresets, data); + await service.write(SettingsService.customShaderPresets, data); } /// Reset to default (no shaders) diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index e518cbfc..a9117a12 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -7,7 +7,8 @@ import '../services/settings_service.dart' as settings; import '../theme/mono_theme.dart'; class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin { - late settings.SettingsService _settingsService; + settings.SettingsService? _settingsService; + ValueNotifier? _themeModeListenable; settings.ThemeMode _themeMode = settings.ThemeMode.system; late Brightness _systemBrightness; @@ -26,6 +27,7 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin { @override void dispose() { + _themeModeListenable?.removeListener(_onThemeModeSettingChanged); if (WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged == _onBrightnessChanged) { WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = null; } @@ -33,10 +35,30 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin { } Future _initializeSettings() async { - _settingsService = await settings.SettingsService.getInstance(); - _themeMode = _settingsService.read(settings.SettingsService.themeMode); - _updateSplashTheme(_themeMode); - safeNotifyListeners(); + final service = await settings.SettingsService.getInstance(); + if (_settingsService == service && _themeModeListenable != null) { + _syncThemeMode(service.read(settings.SettingsService.themeMode)); + return; + } + + _themeModeListenable?.removeListener(_onThemeModeSettingChanged); + _settingsService = service; + _themeModeListenable = service.listenable(settings.SettingsService.themeMode) + ..addListener(_onThemeModeSettingChanged); + _syncThemeMode(_themeModeListenable!.value); + } + + void _onThemeModeSettingChanged() { + final listenable = _themeModeListenable; + if (listenable == null) return; + _syncThemeMode(listenable.value); + } + + void _syncThemeMode(settings.ThemeMode mode, {bool forceNotify = false}) { + final changed = _themeMode != mode; + _themeMode = mode; + _updateSplashTheme(mode); + if (changed || forceNotify) safeNotifyListeners(); } settings.ThemeMode get themeMode => _themeMode; @@ -78,22 +100,18 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin { static const _themeChannel = MethodChannel('com.plezy/theme'); Future setThemeMode(settings.ThemeMode mode) async { - if (_themeMode != mode) { - _themeMode = mode; - await _settingsService.write(settings.SettingsService.themeMode, mode); - _updateSplashTheme(mode); - safeNotifyListeners(); - } + if (_themeMode == mode) return; + final service = _settingsService ?? await settings.SettingsService.getInstance(); + await service.write(settings.SettingsService.themeMode, mode); + if (_themeModeListenable == null) _syncThemeMode(mode); } /// Re-read the theme mode from SharedPreferences. Used after imports or /// resets that change persisted settings outside this provider. Future reload() async { - _settingsService = await settings.SettingsService.getInstance(); - final mode = _settingsService.read(settings.SettingsService.themeMode); - _themeMode = mode; - _updateSplashTheme(mode); - safeNotifyListeners(); + await _initializeSettings(); + final service = _settingsService; + if (service != null) _syncThemeMode(service.read(settings.SettingsService.themeMode), forceNotify: true); } void _updateSplashTheme(settings.ThemeMode mode) { diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 6fc8c386..8450e6d5 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -36,7 +36,8 @@ import '../profiles/profile_connection_registry.dart'; import '../profiles/profile_registry.dart'; import '../providers/user_profile_provider.dart'; import '../services/storage_service.dart'; -import '../providers/settings_provider.dart'; +import '../services/settings_service.dart'; +import '../widgets/settings_builder.dart'; import '../mixins/refreshable.dart'; import '../mixins/tab_visibility_aware.dart'; import '../i18n/strings.g.dart'; @@ -209,7 +210,7 @@ class _DiscoverScreenState extends State return keys; } - bool get _isHeroSectionVisible => _onDeck.isNotEmpty && context.read().showHeroSection; + bool get _isHeroSectionVisible => _onDeck.isNotEmpty && context.settingsRead(SettingsService.showHeroSection); void _scrollToTop() { if (!_scrollController.hasClients) return; @@ -505,14 +506,9 @@ class _DiscoverScreenState extends State if (!mounted) return; _lastSeenHiddenKeys = Set.of(hiddenLibrariesProvider.hiddenLibraryKeys); - // Get settings for hub mode preference (ensure initialized before accessing) - final settingsProvider = Provider.of(context, listen: false); - // Let aggregation service fetch libraries internally; the LibrariesProvider // stores neutral MediaLibrary objects. - await settingsProvider.ensureInitialized(); - // Start OnDeck and hubs fetch in parallel final onDeckFuture = multiServerProvider.aggregationService.getOnDeckFromAllServers( limit: 20, @@ -520,7 +516,7 @@ class _DiscoverScreenState extends State ); final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers( hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, - useGlobalHubs: settingsProvider.useGlobalHubs, + useGlobalHubs: context.settingsRead(SettingsService.useGlobalHubs), ); // Wait for OnDeck to complete and show it immediately @@ -677,7 +673,7 @@ class _DiscoverScreenState extends State await WatchNextService().syncFromOnDeck( onDeck, (serverId) => context.getMediaClientWithFallback(serverId), - hideSpoilers: context.read().hideSpoilers, + hideSpoilers: context.settingsRead(SettingsService.hideSpoilers), ); } catch (e) { appLogger.w('Failed to sync Watch Next', error: e); @@ -1138,8 +1134,20 @@ class _DiscoverScreenState extends State @override Widget build(BuildContext context) { - // Get settings for server name display - final showServerNameOnHubs = context.watch().showServerNameOnHubs; + return SettingsBuilder( + prefs: const [ + SettingsService.showServerNameOnHubs, + SettingsService.showHeroSection, + SettingsService.hideSpoilers, + ], + builder: (context) => _buildContent(context), + ); + } + + Widget _buildContent(BuildContext context) { + final svc = SettingsService.instanceOrNull!; + final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs); + final showHeroSection = svc.read(SettingsService.showHeroSection); final duplicateHubTitles = _getDuplicateHubTitles(); final bottomPadding = MediaQuery.paddingOf(context).bottom; @@ -1152,9 +1160,9 @@ class _DiscoverScreenState extends State controller: _scrollController, slivers: [ // Hero Section (Continue Watching) - at top of screen - Consumer( - builder: (context, settingsProvider, child) { - if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) { + Builder( + builder: (context) { + if (_onDeck.isNotEmpty && showHeroSection) { return _buildHeroSection(); } // Add top padding when hero is not shown @@ -1416,7 +1424,7 @@ class _DiscoverScreenState extends State final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow; // Spoiler protection - final hideSpoilers = context.watch().hideSpoilers; + final hideSpoilers = SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers); final shouldHideSpoiler = hideSpoilers && heroItem.shouldHideSpoiler; // Build semantic label for hero item diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index 4d2b51ae..0ecc3ebe 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -5,7 +5,8 @@ import '../../focus/focusable_action_bar.dart'; import '../../media/media_item.dart'; import '../../providers/download_provider.dart'; import '../../providers/multi_server_provider.dart'; -import '../../providers/settings_provider.dart'; +import '../../services/settings_service.dart'; +import '../../widgets/settings_builder.dart'; import '../../utils/global_key_utils.dart'; import '../../mixins/tab_navigation_mixin.dart'; import '../../utils/grid_size_calculator.dart'; @@ -292,8 +293,8 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> { @override Widget build(BuildContext context) { - return Consumer2( - builder: (context, downloadProvider, settingsProvider, _) { + return Consumer( + builder: (context, downloadProvider, _) { final List items = widget.type == DownloadType.tvShows ? downloadProvider.downloadedShows : downloadProvider.downloadedMovies; @@ -304,33 +305,35 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> { // Extra top padding for focus decoration (scale + border extends beyond item bounds) const effectivePadding = EdgeInsets.only(left: 8, right: 8, top: 8); - final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); - // Use LayoutBuilder to get actual available width (accounting for sidebar) - return LayoutBuilder( - builder: (context, constraints) { - final availableWidth = constraints.maxWidth - effectivePadding.left - effectivePadding.right; - final columnCount = GridSizeCalculator.getColumnCount(availableWidth, maxCrossAxisExtent); + return SettingValueBuilder( + pref: SettingsService.libraryDensity, + builder: (context, density, _) { + final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); + // Use LayoutBuilder to get actual available width (accounting for sidebar) + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth - effectivePadding.left - effectivePadding.right; + final columnCount = GridSizeCalculator.getColumnCount(availableWidth, maxCrossAxisExtent); - return GridView.builder( - padding: effectivePadding, - // Allow focus decoration to render outside scroll bounds - clipBehavior: Clip.none, - gridDelegate: MediaGridDelegate.createDelegate( - context: context, - density: settingsProvider.libraryDensity, - ), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); - final isFirst = index == 0; - return FocusableMediaCard( - item: item, - focusNode: isFirst ? _firstItemFocusNode : null, - onBack: widget.onBack, - isOffline: true, // Downloaded content works without server - onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, + return GridView.builder( + padding: effectivePadding, + // Allow focus decoration to render outside scroll bounds + clipBehavior: Clip.none, + gridDelegate: MediaGridDelegate.createDelegate(context: context, density: density), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); + final isFirst = index == 0; + return FocusableMediaCard( + item: item, + focusNode: isFirst ? _firstItemFocusNode : null, + onBack: widget.onBack, + isOffline: true, // Downloaded content works without server + onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, + ); + }, ); }, ); diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index e0419227..2ea94d5e 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; import '../media/media_item.dart'; import '../media/media_playlist.dart'; import '../mixins/grid_focus_node_mixin.dart'; -import '../providers/settings_provider.dart'; -import '../services/settings_service.dart' show ViewMode; +import '../services/settings_service.dart'; +import '../widgets/settings_builder.dart'; import '../utils/grid_size_calculator.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/media_grid_delegate.dart'; @@ -163,9 +162,12 @@ mixin FocusableDetailScreenMixin on State, GridFocu String? collectionId, VoidCallback? onListRefresh, }) { - return Consumer( - builder: (context, settingsProvider, child) { - final isListMode = settingsProvider.viewMode == ViewMode.list; + return SettingsBuilder( + prefs: const [SettingsService.viewMode, SettingsService.libraryDensity], + builder: (context) { + final svc = SettingsService.instanceOrNull!; + final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; + final libraryDensity = svc.read(SettingsService.libraryDensity); if (isListMode) { return SliverPadding( @@ -193,17 +195,14 @@ mixin FocusableDetailScreenMixin on State, GridFocu ); } - final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); + final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, libraryDensity); return SliverPadding( padding: const EdgeInsets.all(8), sliver: SliverLayoutBuilder( builder: (context, constraints) { final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent); return SliverGrid.builder( - gridDelegate: MediaGridDelegate.createDelegate( - context: context, - density: settingsProvider.libraryDensity, - ), + gridDelegate: MediaGridDelegate.createDelegate(context: context, density: libraryDensity), itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; @@ -242,9 +241,12 @@ mixin FocusableDetailScreenMixin on State, GridFocu String? collectionId, VoidCallback? onListRefresh, }) { - return Consumer( - builder: (context, settingsProvider, child) { - final isListMode = settingsProvider.viewMode == ViewMode.list; + return SettingsBuilder( + prefs: const [SettingsService.viewMode, SettingsService.libraryDensity], + builder: (context) { + final svc = SettingsService.instanceOrNull!; + final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; + final libraryDensity = svc.read(SettingsService.libraryDensity); Widget buildTile(int index, {required bool inFirstRow, required bool disableScale}) { final item = itemAt(index); @@ -277,17 +279,14 @@ mixin FocusableDetailScreenMixin on State, GridFocu ); } - final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); + final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, libraryDensity); return SliverPadding( padding: const EdgeInsets.all(8), sliver: SliverLayoutBuilder( builder: (context, constraints) { final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent); return SliverGrid.builder( - gridDelegate: MediaGridDelegate.createDelegate( - context: context, - density: settingsProvider.libraryDensity, - ), + gridDelegate: MediaGridDelegate.createDelegate(context: context, density: libraryDensity), itemCount: totalItems, itemBuilder: (context, index) => buildTile( index, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 98dbafa2..752a2d5f 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:provider/provider.dart'; import '../media/media_hub.dart'; import '../media/media_item.dart'; import '../media/media_sort.dart'; -import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; +import '../widgets/settings_builder.dart'; import '../utils/app_logger.dart'; import '../utils/grid_size_calculator.dart'; import '../utils/provider_extensions.dart'; @@ -310,11 +309,17 @@ class _HubDetailScreenState extends State else if (_filteredItems.isEmpty) SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound))) else - Builder( + SettingsBuilder( + prefs: const [ + SettingsService.viewMode, + SettingsService.episodePosterMode, + SettingsService.libraryDensity, + ], builder: (context) { - final settings = context.watch(); - final isListMode = settings.viewMode == ViewMode.list; - final episodePosterMode = settings.episodePosterMode; + final svc = SettingsService.instanceOrNull!; + final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; + final episodePosterMode = svc.read(SettingsService.episodePosterMode); + final libraryDensity = svc.read(SettingsService.libraryDensity); // Determine hub content type for layout decisions final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); @@ -360,7 +365,7 @@ class _HubDetailScreenState extends State builder: (context, constraints) { final maxExtent = GridSizeCalculator.getMaxCrossAxisExtentWithPadding( context, - settings.libraryDensity, + libraryDensity, 16, ); final columnCount = GridSizeCalculator.getColumnCount( @@ -371,7 +376,7 @@ class _HubDetailScreenState extends State return SliverGrid( gridDelegate: MediaGridDelegate.createDelegate( context: context, - density: settings.libraryDensity, + density: libraryDensity, usePaddingAware: true, horizontalPadding: 16, useWideAspectRatio: useWideLayout, diff --git a/lib/screens/libraries/adaptive_media_grid.dart b/lib/screens/libraries/adaptive_media_grid.dart index 5a407e2a..002af7c1 100644 --- a/lib/screens/libraries/adaptive_media_grid.dart +++ b/lib/screens/libraries/adaptive_media_grid.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../../providers/settings_provider.dart'; -import '../../services/settings_service.dart' show ViewMode; +import '../../services/settings_service.dart'; +import '../../widgets/settings_builder.dart'; import '../../utils/grid_size_calculator.dart'; import '../../utils/layout_constants.dart'; import '../main_screen.dart'; @@ -73,10 +72,11 @@ class AdaptiveMediaGrid extends StatelessWidget { @override Widget build(BuildContext context) { - return Selector( - selector: (_, p) => (p.viewMode, p.libraryDensity), - builder: (context, slice, _) { - return _buildItemsView(context, slice.$1, slice.$2); + return SettingsBuilder( + prefs: const [SettingsService.viewMode, SettingsService.libraryDensity], + builder: (context) { + final svc = SettingsService.instanceOrNull!; + return _buildItemsView(context, svc.read(SettingsService.viewMode), svc.read(SettingsService.libraryDensity)); }, ); } diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index cd8fabe5..45ba12ab 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -3,14 +3,13 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:provider/provider.dart'; import '../../focus/focusable_button.dart'; import '../../focus/focusable_wrapper.dart'; import '../../media/media_item.dart'; import '../../media/media_item_types.dart'; import '../../media/media_kind.dart'; -import '../../providers/settings_provider.dart'; -import '../../services/settings_service.dart' show EpisodePosterMode; +import '../../services/settings_service.dart'; +import '../../widgets/settings_builder.dart'; import '../../utils/formatters.dart'; import '../../utils/provider_extensions.dart'; import '../../widgets/media_progress_bar.dart'; @@ -140,9 +139,10 @@ class FolderTreeItem extends StatelessWidget { Widget _buildMediaRow(BuildContext context) { final indentation = depth * 24.0; - final episodePosterMode = context.select((s) => s.episodePosterMode); - final hideSpoilers = context.select((s) => s.hideSpoilers); - final showUnwatchedCount = context.select((s) => s.showUnwatchedCount); + final svc = SettingsService.instanceOrNull!; + final episodePosterMode = svc.read(SettingsService.episodePosterMode); + final hideSpoilers = svc.read(SettingsService.hideSpoilers); + final showUnwatchedCount = svc.read(SettingsService.showUnwatchedCount); final isWide = item.usesWideAspectRatio(episodePosterMode); final thumbWidth = isWide ? 130.0 : 53.0; @@ -336,7 +336,16 @@ class FolderTreeItem extends StatelessWidget { @override Widget build(BuildContext context) { - final rowContent = isFolder ? _buildFolderRow(context) : _buildMediaRow(context); + final rowContent = isFolder + ? _buildFolderRow(context) + : SettingsBuilder( + prefs: const [ + SettingsService.episodePosterMode, + SettingsService.hideSpoilers, + SettingsService.showUnwatchedCount, + ], + builder: _buildMediaRow, + ); return Row( children: [ diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index d2b1cf38..8c166964 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -19,7 +19,8 @@ import '../../media/media_library.dart'; import '../../media/media_server_client.dart'; import '../../providers/hidden_libraries_provider.dart'; import '../../providers/libraries_provider.dart'; -import '../../providers/settings_provider.dart'; +import '../../services/settings_service.dart'; +import '../../widgets/settings_builder.dart'; import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/library_grouping.dart'; @@ -998,6 +999,13 @@ class _LibrariesScreenState extends State @override Widget build(BuildContext context) { + return SettingValueBuilder( + pref: SettingsService.groupLibrariesByServer, + builder: (context, groupByServerSetting, _) => _buildContent(context, groupByServerSetting), + ); + } + + Widget _buildContent(BuildContext context, bool groupByServerSetting) { // Watch libraries provider for updates final librariesProvider = context.watch(); final allLibraries = librariesProvider.libraries; @@ -1017,11 +1025,6 @@ class _LibrariesScreenState extends State final showMobileTabsRow = selectedLibrary != null && !PlatformDetector.shouldUseSideNavigation(context); - // Hoist Provider lookup out of any closures: NestedScrollView's - // headerSliverBuilder is invoked from a Builder downstream and Provider - // refuses context.select inside closures invoked from foreign builds. - final groupByServerSetting = context.select((p) => p.groupLibrariesByServer); - Widget appBar({required bool floating}) => DesktopSliverAppBar( title: _buildAppBarTitle(visibleLibraries, selectedLibrary, groupByServer: groupByServerSetting), // When showing the tab content, let the app bar float away with the diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 94e2f990..5a1c76a8 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -14,7 +14,7 @@ import '../../../focus/dpad_navigator.dart'; import '../../../focus/input_mode_tracker.dart'; import '../../../media/media_filter.dart'; import '../../../media/media_sort.dart'; -import '../../../providers/settings_provider.dart'; +import '../../../widgets/settings_builder.dart'; import '../../../services/image_cache_service.dart'; import '../../../services/library_query_translator.dart'; import '../../../services/plex_constants.dart'; @@ -40,7 +40,7 @@ import '../../../widgets/app_icon.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../state_messages.dart'; import '../../../services/storage_service.dart'; -import '../../../services/settings_service.dart' show ViewMode, EpisodePosterMode; +import '../../../services/settings_service.dart'; import '../../../mixins/grid_focus_node_mixin.dart'; import '../../../mixins/item_updatable.dart'; import '../../../mixins/deletion_aware.dart'; @@ -1211,8 +1211,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState(); - final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); + final density = context.settingsRead(SettingsService.libraryDensity); + final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing; final columnCount = ((screenSize.width + crossAxisSpacing) / (maxExtent + crossAxisSpacing)).ceil().clamp(1, 100); final itemWidth = screenSize.width / columnCount; @@ -1400,10 +1400,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState( - builder: (context, settingsProvider, child) { - return _buildItemsSliver(context, settingsProvider); - }, + SettingsBuilder( + prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.episodePosterMode], + builder: (context) => _buildItemsSliver(context), ), ]; } @@ -1418,14 +1417,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 8.0; return SliverPadding( @@ -1464,7 +1466,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState void initState() { super.initState(); suppressAutoFocus = true; - _showFavoritesOnly = context.read().liveTvDefaultFavorites; + _showFavoritesOnly = context.settingsRead(SettingsService.liveTvDefaultFavorites); initTabNavigation(); _loadChannels(); } diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index cab0ecd4..ae1ba732 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -13,7 +13,8 @@ import '../../../media/media_item_types.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_hub_result.dart'; import '../../../providers/multi_server_provider.dart'; -import '../../../providers/settings_provider.dart'; +import '../../../services/settings_service.dart'; +import '../../../widgets/settings_builder.dart'; import '../../../utils/grid_size_calculator.dart'; import '../../../theme/mono_tokens.dart'; import '../../../utils/app_logger.dart'; @@ -423,7 +424,13 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> { @override Widget build(BuildContext context) { final hasFocus = _hubFocusNode.hasFocus; - final settings = context.watch(); + return SettingValueBuilder( + pref: SettingsService.libraryDensity, + builder: (context, libraryDensity, _) => _buildContent(context, hasFocus, libraryDensity), + ); + } + + Widget _buildContent(BuildContext context, bool hasFocus, int libraryDensity) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -455,11 +462,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> { onKeyEvent: _handleKeyEvent, child: LayoutBuilder( builder: (context, constraints) { - final cardWidth = GridSizeCalculator.getCellWidth( - constraints.maxWidth, - context, - settings.libraryDensity, - ); + final cardWidth = GridSizeCalculator.getCellWidth(constraints.maxWidth, context, libraryDensity); final posterWidth = cardWidth - 16; final posterHeight = posterWidth * 1.5; // 2:3 aspect final containerHeight = posterHeight + 66; diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index c76cec52..6abce9e6 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -33,7 +33,7 @@ import '../providers/multi_server_provider.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../providers/playback_state_provider.dart'; -import '../providers/settings_provider.dart'; +import '../widgets/settings_builder.dart'; import '../services/api_cache.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_watch_sync_service.dart'; @@ -1158,9 +1158,9 @@ class _MainScreenState extends State with RouteAware, WindowListener Widget _buildContent(BuildContext context, bool useSideNav) { if (useSideNav) { - return Consumer( - builder: (context, settingsProvider, child) { - final alwaysExpanded = settingsProvider.alwaysKeepSidebarOpen; + return SettingValueBuilder( + pref: SettingsService.alwaysKeepSidebarOpen, + builder: (context, alwaysExpanded, _) { final contentLeftPadding = alwaysExpanded ? SideNavigationRailState.expandedWidth : SideNavigationRailState.collapsedWidth; @@ -1283,9 +1283,10 @@ class _MainScreenState extends State with RouteAware, WindowListener ), ), ), - Consumer( - builder: (context, settingsProvider, child) { - final hideLabels = !settingsProvider.showNavBarLabels; + SettingValueBuilder( + pref: SettingsService.showNavBarLabels, + builder: (context, showNavBarLabels, _) { + final hideLabels = !showNavBarLabels; return NavigationBarTheme( data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null), child: NavigationBar( diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index ccf0ff74..0cdc075c 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -41,7 +41,8 @@ import '../models/download_models.dart'; import '../services/download_storage_service.dart'; import '../utils/download_version_utils.dart'; import '../utils/download_utils.dart'; -import '../providers/settings_provider.dart'; +import '../services/settings_service.dart'; +import '../widgets/settings_builder.dart'; import '../utils/grid_size_calculator.dart'; import '../providers/download_provider.dart'; import '../providers/offline_watch_provider.dart'; @@ -1416,7 +1417,7 @@ class _MediaDetailScreenState extends State /// Get the responsive card width used by seasons/extras/cast rows. /// Uses the shared grid size calculator for consistency with library grids. double _getResponsiveCardWidth() { - final density = context.read().libraryDensity; + final density = SettingsService.instanceOrNull!.read(SettingsService.libraryDensity); final availableWidth = MediaQuery.sizeOf(context).width; return GridSizeCalculator.getCellWidth(availableWidth, context, density); } @@ -1486,7 +1487,13 @@ class _MediaDetailScreenState extends State /// Build inline season tab chips with LEFT/RIGHT/DOWN focus navigation Widget _buildSeasonTabs() { - final showPosters = context.select((p) => p.showSeasonPostersOnTabs); + return SettingValueBuilder( + pref: SettingsService.showSeasonPostersOnTabs, + builder: (context, showPosters, _) => _buildSeasonTabsContent(context, showPosters), + ); + } + + Widget _buildSeasonTabsContent(BuildContext context, bool showPosters) { return HorizontalScrollWithArrows( controller: _seasonTabsScrollController, builder: (scrollController) => SingleChildScrollView( @@ -2568,6 +2575,13 @@ class _MediaDetailScreenState extends State /// Build the cast section with locked focus pattern for D-pad navigation /// Uses same layout pattern as seasons/extras (ListView.builder + Padding(horizontal: 2)) Widget _buildCastSection(MediaItem metadata) { + return SettingValueBuilder( + pref: SettingsService.libraryDensity, + builder: (context, libraryDensity, child) => _buildCastSectionContent(metadata), + ); + } + + Widget _buildCastSectionContent(MediaItem metadata) { final cardWidth = _getResponsiveCardWidth(); const innerPadding = 3.0; final imageSize = cardWidth; @@ -2655,6 +2669,13 @@ class _MediaDetailScreenState extends State } Widget _buildExtrasSection() { + return SettingValueBuilder( + pref: SettingsService.libraryDensity, + builder: (context, libraryDensity, child) => _buildExtrasSectionContent(), + ); + } + + Widget _buildExtrasSectionContent() { final cardWidth = _getResponsiveCardWidth(); // 16:9 aspect ratio for clip thumbnails (cardWidth includes 8px padding on each side) final posterHeight = (cardWidth - 16) * (9 / 16); diff --git a/lib/screens/settings/about_screen.dart b/lib/screens/settings/about_screen.dart index ee74ae42..04574bef 100644 --- a/lib/screens/settings/about_screen.dart +++ b/lib/screens/settings/about_screen.dart @@ -6,90 +6,74 @@ import '../../widgets/focused_scroll_scaffold.dart'; import '../../i18n/strings.g.dart'; import 'licenses_screen.dart'; -class AboutScreen extends StatefulWidget { +class AboutScreen extends StatelessWidget { const AboutScreen({super.key}); - @override - State createState() => _AboutScreenState(); -} - -class _AboutScreenState extends State { - String _appName = ''; - String _appVersion = ''; - - @override - void initState() { - super.initState(); - _loadPackageInfo(); - } - - Future _loadPackageInfo() async { - final packageInfo = await PackageInfo.fromPlatform(); - if (!mounted) return; - setState(() { - _appName = t.app.title; - _appVersion = packageInfo.version; - }); - } + static final Future _packageInfoFuture = PackageInfo.fromPlatform(); @override Widget build(BuildContext context) { - final appName = _appName; - final appVersion = _appVersion; + final appName = t.app.title; - return FocusedScrollScaffold( - title: Text(t.about.title), - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - // App Icon and Name - Center( - child: Column( - children: [ - const SizedBox(height: 24), - Image.asset('assets/plezy.png', width: 80, height: 80), - const SizedBox(height: 16), - Text( - appName, - style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), + return FutureBuilder( + future: _packageInfoFuture, + builder: (context, snapshot) { + final appVersion = snapshot.data?.version ?? ''; + return FocusedScrollScaffold( + title: Text(t.about.title), + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + // App Icon and Name + Center( + child: Column( + children: [ + const SizedBox(height: 24), + Image.asset('assets/plezy.png', width: 80, height: 80), + const SizedBox(height: 16), + Text( + appName, + style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + t.about.versionLabel(version: appVersion), + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 24), + Text( + t.about.appDescription, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ], ), - const SizedBox(height: 8), - Text( - t.about.versionLabel(version: appVersion), - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), + ), + + const SizedBox(height: 40), + + // Open Source Licenses + Card( + child: ListTile( + leading: const AppIcon(Symbols.description_rounded, fill: 1), + title: Text(t.about.openSourceLicenses), + subtitle: Text(t.about.viewLicensesDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const LicensesScreen())); + }, ), - const SizedBox(height: 24), - Text( - t.about.appDescription, - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - ], - ), + ), + + const SizedBox(height: 24), + ]), ), - - const SizedBox(height: 40), - - // Open Source Licenses - Card( - child: ListTile( - leading: const AppIcon(Symbols.description_rounded, fill: 1), - title: Text(t.about.openSourceLicenses), - subtitle: Text(t.about.viewLicensesDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const LicensesScreen())); - }, - ), - ), - - const SizedBox(height: 24), - ]), - ), - ), - ], + ), + ], + ); + }, ); } } diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 4d3c513f..31ba8b34 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -6,86 +6,145 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; -import '../../providers/settings_provider.dart'; import '../../providers/theme_provider.dart'; import '../../profiles/active_profile_provider.dart'; -import '../../services/settings_service.dart' as settings; +import '../../services/settings_service.dart' hide ThemeMode; +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_builder.dart'; import '../../widgets/settings_section.dart'; import 'settings_utils.dart'; -class AppearanceSettingsScreen extends StatefulWidget { +class AppearanceSettingsScreen extends StatelessWidget { const AppearanceSettingsScreen({super.key}); - @override - State createState() => _AppearanceSettingsScreenState(); -} - -class _AppearanceSettingsScreenState extends State { - late settings.SettingsService _settingsService; - bool _isLoading = true; - - @override - void initState() { - super.initState(); - _loadSettings(); - } - - Future _loadSettings() async { - _settingsService = await settings.SettingsService.getInstance(); - if (!mounted) return; - setState(() => _isLoading = false); - } - @override Widget build(BuildContext context) { - if (_isLoading) { - return FocusedScrollScaffold( - title: Text(t.settings.appearance), - slivers: [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], - ); - } - return FocusedScrollScaffold( title: Text(t.settings.appearance), slivers: [ SliverList( delegate: SliverChildListDelegate([ SettingsSectionHeader(t.settings.display), - _buildThemeSelector(), - _buildLanguageSelector(), - _buildDensitySelector(), - _buildViewModeSelector(), - _buildEpisodePosterModeSelector(), - _buildShowEpisodeNumberOnCards(), - _buildShowSeasonPostersOnTabs(), + _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), - _buildShowHeroSection(), - _buildUseGlobalHubs(), - _buildShowServerNameOnHubs(), + 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) _buildForceTvMode(), - if (PlatformDetector.shouldUseSideNavigation(context)) _buildAlwaysKeepSidebarOpen(), - if (PlatformDetector.shouldUseSideNavigation(context)) _buildGroupLibrariesByServer(), - if (!PlatformDetector.shouldUseSideNavigation(context)) _buildShowNavBarLabels(), - _buildShowUnwatchedCount(), + 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), - _buildStartInFullscreen(), + SettingSwitchTile( + pref: SettingsService.startInFullscreen, + icon: Symbols.fullscreen_rounded, + title: t.settings.startInFullscreen, + subtitle: t.settings.startInFullscreenDescription, + ), ], SettingsSectionHeader(t.settings.content), - _buildLiveTvDefaultFavorites(), - _buildHideSpoilers(), - _buildRequireProfileSelection(), - if (PlatformDetector.isTV()) _buildConfirmExitOnBack(), - _buildAutoHidePerformanceOverlay(), + 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), ]), ), @@ -93,9 +152,9 @@ class _AppearanceSettingsScreenState extends State { ); } - Widget _buildThemeSelector() { + Widget _themeSelector() { return Consumer( - builder: (context, themeProvider, child) { + builder: (context, themeProvider, _) { return SegmentedSetting( icon: themeProvider.themeModeIcon, title: t.settings.theme, @@ -106,13 +165,13 @@ class _AppearanceSettingsScreenState extends State { ButtonSegment(value: settings.ThemeMode.oled, label: Text(t.settings.oledTheme)), ], selected: themeProvider.themeMode, - onChanged: (value) => themeProvider.setThemeMode(value), + onChanged: themeProvider.setThemeMode, ); }, ); } - Widget _buildLanguageSelector() { + Widget _languageSelector(BuildContext context) { return ListTile( leading: const AppIcon(Symbols.language_rounded, fill: 1), title: Text(t.settings.language), @@ -128,265 +187,79 @@ class _AppearanceSettingsScreenState extends State { currentValue: LocaleSettings.currentLocale, ); if (value != null) { - await _settingsService.write(settings.SettingsService.appLocale, value); + await SettingsService.instanceOrNull!.write(SettingsService.appLocale, value); unawaited(LocaleSettings.setLocale(value)); - _restartApp(); + if (context.mounted) _restartApp(context); } }, ); } - Widget _buildDensitySelector() { - return Selector( - selector: (_, p) => p.libraryDensity, - builder: (context, density, _) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const AppIcon(Symbols.grid_view_rounded, fill: 1), - const SizedBox(width: 16), - Text(t.settings.compact, style: const TextStyle(fontSize: 12, color: Colors.grey)), - Expanded( - child: FocusableSlider( - value: density.toDouble(), - min: 1, - max: 5, - divisions: 4, - onChanged: (value) => context.read().setLibraryDensity(value.round()), - ), - ), - Text(t.settings.comfortable, style: const TextStyle(fontSize: 12, color: Colors.grey)), - ], + Widget _densitySelector() { + return SettingValueBuilder( + pref: SettingsService.libraryDensity, + builder: (_, density, _) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const AppIcon(Symbols.grid_view_rounded, fill: 1), + const SizedBox(width: 16), + Text(t.settings.compact, style: const TextStyle(fontSize: 12, color: Colors.grey)), + Expanded( + child: FocusableSlider( + value: density.toDouble(), + min: 1, + max: 5, + divisions: 4, + onChanged: (v) => SettingsService.instanceOrNull!.write(SettingsService.libraryDensity, v.round()), ), - ], - ), - ); - }, - ); - } - - Widget _buildViewModeSelector() { - return Selector( - selector: (_, p) => p.viewMode, - builder: (context, viewMode, _) { - return SegmentedSetting( - icon: Symbols.view_list_rounded, - title: t.settings.viewMode, - segments: [ - ButtonSegment(value: settings.ViewMode.grid, label: Text(t.settings.gridView)), - ButtonSegment(value: settings.ViewMode.list, label: Text(t.settings.listView)), + ), + Text(t.settings.comfortable, style: const TextStyle(fontSize: 12, color: Colors.grey)), ], - selected: viewMode, - onChanged: (value) => context.read().setViewMode(value), - ); - }, - ); - } - - Widget _buildEpisodePosterModeSelector() { - return Selector( - selector: (_, p) => p.episodePosterMode, - builder: (context, mode, _) { - return SegmentedSetting( - icon: Symbols.image_rounded, - title: t.settings.episodePosterMode, - segments: [ - ButtonSegment(value: settings.EpisodePosterMode.seriesPoster, label: Text(t.settings.seriesPoster)), - ButtonSegment(value: settings.EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)), - ButtonSegment(value: settings.EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)), - ], - selected: mode, - onChanged: (value) => context.read().setEpisodePosterMode(value), - ); - }, - ); - } - - Widget _buildShowEpisodeNumberOnCards() => _buildBoolToggle( - icon: Symbols.tag_rounded, - title: t.settings.showEpisodeNumberOnCards, - subtitle: t.settings.showEpisodeNumberOnCardsDescription, - getter: (p) => p.showEpisodeNumberOnCards, - setter: (p, v) => p.setShowEpisodeNumberOnCards(v), - ); - - Widget _buildShowSeasonPostersOnTabs() => _buildBoolToggle( - icon: Symbols.image_rounded, - title: t.settings.showSeasonPostersOnTabs, - subtitle: t.settings.showSeasonPostersOnTabsDescription, - getter: (p) => p.showSeasonPostersOnTabs, - setter: (p, v) => p.setShowSeasonPostersOnTabs(v), - ); - - /// Shared scaffolding for the bool toggles on this screen. Each toggle - /// watches one `SettingsProvider` field via `Selector`, so flipping one - /// switch doesn't rebuild the others. - Widget _buildBoolToggle({ - required IconData icon, - required String title, - required String subtitle, - required bool Function(SettingsProvider) getter, - required Future Function(SettingsProvider, bool) setter, - }) { - return Selector( - selector: (_, p) => getter(p), - builder: (context, value, _) => SwitchListTile( - secondary: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: Text(subtitle), - value: value, - onChanged: (v) => setter(context.read(), v), + ), ), ); } - Widget _buildShowHeroSection() => _buildBoolToggle( - icon: Symbols.featured_play_list_rounded, - title: t.settings.showHeroSection, - subtitle: t.settings.showHeroSectionDescription, - getter: (p) => p.showHeroSection, - setter: (p, v) => p.setShowHeroSection(v), + 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)), + ], + decode: (v) => v, + encode: (v) => v, ); - Widget _buildUseGlobalHubs() => _buildBoolToggle( - icon: Symbols.home_rounded, - title: t.settings.useGlobalHubs, - subtitle: t.settings.useGlobalHubsDescription, - getter: (p) => p.useGlobalHubs, - setter: (p, v) => p.setUseGlobalHubs(v), + 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)), + ], + decode: (v) => v, + encode: (v) => v, ); - Widget _buildShowServerNameOnHubs() => _buildBoolToggle( - icon: Symbols.dns_rounded, - title: t.settings.showServerNameOnHubs, - subtitle: t.settings.showServerNameOnHubsDescription, - getter: (p) => p.showServerNameOnHubs, - setter: (p, v) => p.setShowServerNameOnHubs(v), - ); - - Widget _buildAlwaysKeepSidebarOpen() => _buildBoolToggle( - icon: Symbols.dock_to_left_rounded, - title: t.settings.alwaysKeepSidebarOpen, - subtitle: t.settings.alwaysKeepSidebarOpenDescription, - getter: (p) => p.alwaysKeepSidebarOpen, - setter: (p, v) => p.setAlwaysKeepSidebarOpen(v), - ); - - Widget _buildGroupLibrariesByServer() => _buildBoolToggle( - icon: Symbols.dns_rounded, - title: t.settings.groupLibrariesByServer, - subtitle: t.settings.groupLibrariesByServerDescription, - getter: (p) => p.groupLibrariesByServer, - setter: (p, v) => p.setGroupLibrariesByServer(v), - ); - - Widget _buildShowNavBarLabels() => _buildBoolToggle( - icon: Symbols.label_rounded, - title: t.settings.showNavBarLabels, - subtitle: t.settings.showNavBarLabelsDescription, - getter: (p) => p.showNavBarLabels, - setter: (p, v) => p.setShowNavBarLabels(v), - ); - - Widget _buildShowUnwatchedCount() => _buildBoolToggle( - icon: Symbols.counter_1_rounded, - title: t.settings.showUnwatchedCount, - subtitle: t.settings.showUnwatchedCountDescription, - getter: (p) => p.showUnwatchedCount, - setter: (p, v) => p.setShowUnwatchedCount(v), - ); - - Widget _buildLiveTvDefaultFavorites() => _buildBoolToggle( - icon: Symbols.star_rounded, - title: t.settings.liveTvDefaultFavorites, - subtitle: t.settings.liveTvDefaultFavoritesDescription, - getter: (p) => p.liveTvDefaultFavorites, - setter: (p, v) => p.setLiveTvDefaultFavorites(v), - ); - - Widget _buildHideSpoilers() => _buildBoolToggle( - icon: Symbols.visibility_off_rounded, - title: t.settings.hideSpoilers, - subtitle: t.settings.hideSpoilersDescription, - getter: (p) => p.hideSpoilers, - setter: (p, v) => p.setHideSpoilers(v), - ); - - Widget _buildAutoHidePerformanceOverlay() => _buildBoolToggle( - icon: Symbols.speed_rounded, - title: t.settings.autoHidePerformanceOverlay, - subtitle: t.settings.autoHidePerformanceOverlayDescription, - getter: (p) => p.autoHidePerformanceOverlay, - setter: (p, v) => p.setAutoHidePerformanceOverlay(v), - ); - - Widget _buildRequireProfileSelection() { + Widget _requireProfileSelection() { return Consumer( - builder: (context, activeProvider, child) { + builder: (context, activeProvider, _) { if (!activeProvider.hasMultipleProfiles) return const SizedBox.shrink(); - return _buildListenableSwitch( + return SettingSwitchTile( + pref: SettingsService.requireProfileSelectionOnOpen, icon: Symbols.person_rounded, title: t.settings.requireProfileSelectionOnOpen, subtitle: t.settings.requireProfileSelectionOnOpenDescription, - pref: settings.SettingsService.requireProfileSelectionOnOpen, ); }, ); } - Widget _buildConfirmExitOnBack() => _buildListenableSwitch( - icon: Symbols.exit_to_app_rounded, - title: t.settings.confirmExitOnBack, - subtitle: t.settings.confirmExitOnBackDescription, - pref: settings.SettingsService.confirmExitOnBack, - ); - - Widget _buildStartInFullscreen() => _buildListenableSwitch( - icon: Symbols.fullscreen_rounded, - title: t.settings.startInFullscreen, - subtitle: t.settings.startInFullscreenDescription, - pref: settings.SettingsService.startInFullscreen, - ); - - Widget _buildForceTvMode() => _buildListenableSwitch( - icon: Symbols.tv_rounded, - title: t.settings.forceTvMode, - subtitle: t.settings.forceTvModeDescription, - pref: settings.SettingsService.forceTvMode, - onAfterWrite: (value) { - TvDetectionService.setForceTVSync(value); - if (mounted) _restartApp(); - }, - ); - - /// Generic bool toggle that listens to a [Pref] directly via [ValueNotifier]. - /// No local state; rebuilds across the app stay consistent. - Widget _buildListenableSwitch({ - required IconData icon, - required String title, - required String subtitle, - required settings.Pref pref, - void Function(bool)? onAfterWrite, - }) { - return ValueListenableBuilder( - valueListenable: _settingsService.listenable(pref), - builder: (_, value, _) => SwitchListTile( - secondary: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: Text(subtitle), - value: value, - onChanged: (v) async { - await _settingsService.write(pref, v); - onAfterWrite?.call(v); - }, - ), - ); - } - String _getLanguageDisplayName(AppLocale locale) { switch (locale) { case AppLocale.en: @@ -422,7 +295,7 @@ class _AppearanceSettingsScreenState extends State { } } - void _restartApp() { + void _restartApp(BuildContext context) { Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false); } } diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index 6d2067c3..3917c6e5 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -2,94 +2,80 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/widgets/app_icon.dart'; + import '../../focus/focusable_button.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_section.dart'; -class ExternalPlayerScreen extends StatefulWidget { +class ExternalPlayerScreen extends StatelessWidget { const ExternalPlayerScreen({super.key}); - @override - State createState() => _ExternalPlayerScreenState(); -} - -class _ExternalPlayerScreenState extends State { - late SettingsService _settingsService; - bool _isLoading = true; - - bool _useExternalPlayer = false; - ExternalPlayer _selectedPlayer = KnownPlayers.systemDefault; - List _customPlayers = []; - - @override - void initState() { - super.initState(); - _loadSettings(); - } - - Future _loadSettings() async { - _settingsService = await SettingsService.getInstance(); - - if (!mounted) return; - setState(() { - _useExternalPlayer = _settingsService.read(SettingsService.useExternalPlayer); - _selectedPlayer = _settingsService.read(SettingsService.selectedExternalPlayer); - _customPlayers = _settingsService.read(SettingsService.customExternalPlayers); - _isLoading = false; - }); - } - @override Widget build(BuildContext context) { - if (_isLoading) { - return FocusedScrollScaffold( - title: Text(t.externalPlayer.title), - slivers: [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], - ); - } - final knownPlayers = KnownPlayers.getForCurrentPlatform(); - return FocusedScrollScaffold( title: Text(t.externalPlayer.title), slivers: [ SliverList( delegate: SliverChildListDelegate([ - SwitchListTile( - secondary: const AppIcon(Symbols.open_in_new_rounded, fill: 1), - title: Text(t.externalPlayer.useExternalPlayer), - subtitle: Text(t.externalPlayer.useExternalPlayerDescription), - value: _useExternalPlayer, - onChanged: (value) async { - setState(() => _useExternalPlayer = value); - await _settingsService.write(SettingsService.useExternalPlayer, value); + 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), + ), + ], + ); }, ), - if (_useExternalPlayer) ...[ - SettingsSectionHeader(t.externalPlayer.selectPlayer), - ...knownPlayers.map((player) => _buildPlayerTile(player)), - SettingsSectionHeader(t.externalPlayer.customPlayers), - ..._customPlayers.map((player) => _buildPlayerTile(player, isCustom: true)), - ListTile( - leading: const AppIcon(Symbols.add_rounded, fill: 1), - title: Text(t.externalPlayer.addCustomPlayer), - onTap: _showAddCustomPlayerDialog, - ), - ], const SizedBox(height: 24), ]), ), ], ); } +} - Widget _buildPlayerTile(ExternalPlayer player, {bool isCustom = false}) { - final isSelected = _selectedPlayer.id == player.id; +class _PlayerTile extends StatelessWidget { + final ExternalPlayer player; + final String selectedId; + final bool isCustom; + + const _PlayerTile({required this.player, required this.selectedId, this.isCustom = false}); + + @override + Widget build(BuildContext context) { + final isSelected = selectedId == player.id; + final svc = SettingsService.instanceOrNull!; Widget leading; if (player.iconAsset != null) { @@ -101,9 +87,7 @@ class _ExternalPlayerScreenState extends State { player.iconAsset!, width: 32, height: 32, - errorBuilder: (_, _, _) { - return const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32); - }, + errorBuilder: (_, _, _) => const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32), ), ); } else if (player.id == 'system_default') { @@ -121,7 +105,7 @@ class _ExternalPlayerScreenState extends State { if (isCustom) IconButton( icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20), - onPressed: () => _deleteCustomPlayer(player), + onPressed: () => svc.removeCustomExternalPlayer(player.id), ), AppIcon( isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, @@ -130,138 +114,121 @@ class _ExternalPlayerScreenState extends State { ), ], ), - onTap: () async { - setState(() => _selectedPlayer = player); - await _settingsService.write(SettingsService.selectedExternalPlayer, player); - }, + onTap: () => svc.write(SettingsService.selectedExternalPlayer, player), ); } +} - Future _deleteCustomPlayer(ExternalPlayer player) async { - await _settingsService.removeCustomExternalPlayer(player.id); - if (!mounted) return; - setState(() { - _customPlayers.removeWhere((p) => p.id == player.id); - _selectedPlayer = _settingsService.read(SettingsService.selectedExternalPlayer); - }); - } +Future _showAddCustomPlayerDialog(BuildContext context) async { + final nameController = TextEditingController(); + final valueController = TextEditingController(); + final valueFocusNode = FocusNode(); + final saveFocusNode = FocusNode(); + var selectedType = CustomPlayerType.command; - Future _showAddCustomPlayerDialog() async { - final nameController = TextEditingController(); - final valueController = TextEditingController(); - final valueFocusNode = FocusNode(); - final saveFocusNode = FocusNode(); - var selectedType = CustomPlayerType.command; + final result = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) { + final isUrlScheme = selectedType == CustomPlayerType.urlScheme; + final String fieldLabel; + final String fieldHint; + if (isUrlScheme) { + fieldLabel = t.externalPlayer.playerUrlScheme; + fieldHint = 'myplayer://play?url='; + } else if (Platform.isAndroid) { + fieldLabel = t.externalPlayer.playerPackage; + fieldHint = 'com.example.player'; + } else { + fieldLabel = t.externalPlayer.playerCommand; + fieldHint = Platform.isMacOS ? 'mpv' : '/usr/bin/player'; + } - final result = await showDialog( - context: context, - builder: (context) => StatefulBuilder( - builder: (context, setDialogState) { - final isUrlScheme = selectedType == CustomPlayerType.urlScheme; - final String fieldLabel; - final String fieldHint; - if (isUrlScheme) { - fieldLabel = t.externalPlayer.playerUrlScheme; - fieldHint = 'myplayer://play?url='; - } else if (Platform.isAndroid) { - fieldLabel = t.externalPlayer.playerPackage; - fieldHint = 'com.example.player'; - } else { - fieldLabel = t.externalPlayer.playerCommand; - fieldHint = Platform.isMacOS ? 'mpv' : '/usr/bin/player'; - } - - return AlertDialog( - title: Text(t.externalPlayer.addCustomPlayer), - content: SizedBox( - width: 300, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameController, - decoration: InputDecoration(labelText: t.externalPlayer.playerName, hintText: 'My Player'), - autofocus: true, - textInputAction: TextInputAction.next, - onSubmitted: (_) => primaryFocus?.nextFocus(), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: SegmentedButton( - segments: [ - ButtonSegment( - value: CustomPlayerType.command, - label: Text( - Platform.isAndroid ? t.externalPlayer.playerPackage : t.externalPlayer.playerCommand, - ), + return AlertDialog( + title: Text(t.externalPlayer.addCustomPlayer), + content: SizedBox( + width: 300, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: InputDecoration(labelText: t.externalPlayer.playerName, hintText: 'My Player'), + autofocus: true, + textInputAction: TextInputAction.next, + onSubmitted: (_) => primaryFocus?.nextFocus(), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: [ + ButtonSegment( + value: CustomPlayerType.command, + label: Text( + Platform.isAndroid ? t.externalPlayer.playerPackage : t.externalPlayer.playerCommand, ), - ButtonSegment(value: CustomPlayerType.urlScheme, label: Text(t.externalPlayer.playerUrlScheme)), - ], - selected: {selectedType}, - onSelectionChanged: (value) { - setDialogState(() => selectedType = value.first); - }, - ), + ), + ButtonSegment(value: CustomPlayerType.urlScheme, label: Text(t.externalPlayer.playerUrlScheme)), + ], + selected: {selectedType}, + onSelectionChanged: (value) => setDialogState(() => selectedType = value.first), ), - const SizedBox(height: 16), - TextField( - controller: valueController, - focusNode: valueFocusNode, - decoration: InputDecoration(labelText: fieldLabel, hintText: fieldHint), - textInputAction: TextInputAction.done, - onSubmitted: (_) => saveFocusNode.requestFocus(), - ), - ], - ), + ), + const SizedBox(height: 16), + TextField( + controller: valueController, + focusNode: valueFocusNode, + decoration: InputDecoration(labelText: fieldLabel, hintText: fieldHint), + textInputAction: TextInputAction.done, + onSubmitted: (_) => saveFocusNode.requestFocus(), + ), + ], ), - actions: [ - FocusableButton( - onPressed: () => Navigator.pop(context), - child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - ), - FocusableButton( - focusNode: saveFocusNode, + ), + actions: [ + FocusableButton( + onPressed: () => Navigator.pop(context), + child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), + ), + FocusableButton( + focusNode: saveFocusNode, + onPressed: () { + if (nameController.text.isNotEmpty && valueController.text.isNotEmpty) { + Navigator.pop(context, true); + } + }, + child: FilledButton( onPressed: () { if (nameController.text.isNotEmpty && valueController.text.isNotEmpty) { Navigator.pop(context, true); } }, - child: FilledButton( - onPressed: () { - if (nameController.text.isNotEmpty && valueController.text.isNotEmpty) { - Navigator.pop(context, true); - } - }, - child: Text(t.common.save), - ), + child: Text(t.common.save), ), - ], - ); - }, - ), - ); + ), + ], + ); + }, + ), + ); - valueFocusNode.dispose(); - saveFocusNode.dispose(); + valueFocusNode.dispose(); + saveFocusNode.dispose(); - if (result != true) return; + if (result != true) return; - final id = 'custom_${DateTime.now().millisecondsSinceEpoch}'; - final newPlayer = ExternalPlayer.custom( - id: id, - name: nameController.text, - value: valueController.text, - type: selectedType, - ); + final id = 'custom_${DateTime.now().millisecondsSinceEpoch}'; + final newPlayer = ExternalPlayer.custom( + id: id, + name: nameController.text, + value: valueController.text, + type: selectedType, + ); - await _settingsService.write(SettingsService.customExternalPlayers, [ - ..._settingsService.read(SettingsService.customExternalPlayers), - newPlayer, - ]); - if (!mounted) return; - setState(() { - _customPlayers.add(newPlayer); - }); - } + final svc = SettingsService.instanceOrNull!; + await svc.write(SettingsService.customExternalPlayers, [ + ...svc.read(SettingsService.customExternalPlayers), + newPlayer, + ]); } diff --git a/lib/screens/settings/keyboard_shortcuts_screen.dart b/lib/screens/settings/keyboard_shortcuts_screen.dart index 784d8d4c..d131b7ea 100644 --- a/lib/screens/settings/keyboard_shortcuts_screen.dart +++ b/lib/screens/settings/keyboard_shortcuts_screen.dart @@ -7,121 +7,95 @@ import '../../utils/snackbar_helper.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import 'hotkey_recorder_widget.dart'; -class KeyboardShortcutsScreen extends StatefulWidget { +class KeyboardShortcutsScreen extends StatelessWidget { final KeyboardShortcutsService keyboardService; const KeyboardShortcutsScreen({super.key, required this.keyboardService}); - @override - State createState() => _KeyboardShortcutsScreenState(); -} - -class _KeyboardShortcutsScreenState extends State { - Map _hotkeys = {}; - bool _isLoading = true; - - @override - void initState() { - super.initState(); - _loadHotkeys(); - } - - Future _loadHotkeys() async { - await widget.keyboardService.refreshFromStorage(); - if (!mounted) return; - setState(() { - _hotkeys = widget.keyboardService.hotkeys; - _isLoading = false; - }); - } - @override Widget build(BuildContext context) { - return FocusedScrollScaffold( - title: Text(t.settings.keyboardShortcuts), - actions: [ - TextButton( - onPressed: () async { - await widget.keyboardService.resetToDefaults(); - await _loadHotkeys(); - if (mounted) { - showSuccessSnackBar(this.context, t.settings.shortcutsReset); - } - }, - child: Text(t.common.reset), - ), - ], - slivers: _isLoading - ? [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))] - : [ - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final actions = _hotkeys.keys.toList(); - final action = actions[index]; - final hotkey = _hotkeys[action]!; + return ListenableBuilder( + listenable: keyboardService, + builder: (context, _) { + final hotkeys = keyboardService.hotkeys; + final actions = hotkeys.keys.toList(); + return FocusedScrollScaffold( + title: Text(t.settings.keyboardShortcuts), + actions: [ + TextButton( + onPressed: () async { + await keyboardService.resetToDefaults(); + if (context.mounted) showSuccessSnackBar(context, t.settings.shortcutsReset); + }, + child: Text(t.common.reset), + ), + ], + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final action = actions[index]; + final hotkey = hotkeys[action]!; - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ListTile( - title: Text(widget.keyboardService.getActionDisplayName(action)), - subtitle: Text(action), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)), - borderRadius: const BorderRadius.all(Radius.circular(6)), - ), - child: Text( - widget.keyboardService.formatHotkey(hotkey), - style: const TextStyle(fontFamily: 'monospace'), - ), + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(keyboardService.getActionDisplayName(action)), + subtitle: Text(action), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)), + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + child: Text( + keyboardService.formatHotkey(hotkey), + style: const TextStyle(fontFamily: 'monospace'), ), - onTap: () => _editHotkey(action, hotkey), ), - ); - }, childCount: _hotkeys.length), - ), + onTap: () => _editHotkey(context, action, hotkey), + ), + ); + }, childCount: actions.length), ), - ], + ), + ], + ); + }, ); } - void _editHotkey(String action, HotKey currentHotkey) { + void _editHotkey(BuildContext screenContext, String action, HotKey currentHotkey) { showDialog( - context: context, + context: screenContext, builder: (BuildContext context) { return HotKeyRecorderWidget( - actionName: widget.keyboardService.getActionDisplayName(action), + actionName: keyboardService.getActionDisplayName(action), currentHotKey: currentHotkey, onHotKeyRecorded: (newHotkey) async { final navigator = Navigator.of(context); // Check for conflicts - final existingAction = widget.keyboardService.getActionForHotkey(newHotkey); + final existingAction = keyboardService.getActionForHotkey(newHotkey); if (existingAction != null && existingAction != action) { navigator.pop(); showErrorSnackBar( context, - t.settings.shortcutAlreadyAssigned(action: widget.keyboardService.getActionDisplayName(existingAction)), + t.settings.shortcutAlreadyAssigned(action: keyboardService.getActionDisplayName(existingAction)), ); return; } // Save the new hotkey - await widget.keyboardService.setHotkey(action, newHotkey); + await keyboardService.setHotkey(action, newHotkey); - if (mounted) { - setState(() { - _hotkeys[action] = newHotkey; - }); - - navigator.pop(); + navigator.pop(); + if (screenContext.mounted) { showSuccessSnackBar( - this.context, - t.settings.shortcutUpdated(action: widget.keyboardService.getActionDisplayName(action)), + screenContext, + t.settings.shortcutUpdated(action: keyboardService.getActionDisplayName(action)), ); } }, diff --git a/lib/screens/settings/licenses_screen.dart b/lib/screens/settings/licenses_screen.dart index 2a0ec8b3..e42dcd3f 100644 --- a/lib/screens/settings/licenses_screen.dart +++ b/lib/screens/settings/licenses_screen.dart @@ -13,24 +13,12 @@ class MergedLicenseEntry { MergedLicenseEntry({required this.packageName, required this.licenseEntries, required this.allPackageNames}); } -class LicensesScreen extends StatefulWidget { +class LicensesScreen extends StatelessWidget { const LicensesScreen({super.key}); - @override - State createState() => _LicensesScreenState(); -} + static final Future> _licensesFuture = _loadLicenses(); -class _LicensesScreenState extends State { - List _mergedLicenses = []; - bool _isLoading = true; - - @override - void initState() { - super.initState(); - _loadLicenses(); - } - - Future _loadLicenses() async { + static Future> _loadLicenses() async { final licenseMap = >{}; final allPackageNames = >{}; @@ -54,56 +42,56 @@ class _LicensesScreenState extends State { }).toList(); mergedLicenses.sort((a, b) => a.packageName.compareTo(b.packageName)); - - if (mounted) { - setState(() { - _mergedLicenses = mergedLicenses; - _isLoading = false; - }); - } + return mergedLicenses; } @override Widget build(BuildContext context) { - if (_isLoading) { - return FocusedScrollScaffold( - title: Text(t.screens.licenses), - slivers: const [SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], - ); - } + return FutureBuilder>( + future: _licensesFuture, + builder: (context, snapshot) { + final mergedLicenses = snapshot.data; + if (mergedLicenses == null) { + return FocusedScrollScaffold( + title: Text(t.screens.licenses), + slivers: const [SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], + ); + } - return FocusedScrollScaffold( - title: Text(t.screens.licenses), - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final mergedLicense = _mergedLicenses[index]; - final packageName = mergedLicense.packageName; + return FocusedScrollScaffold( + title: Text(t.screens.licenses), + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final mergedLicense = mergedLicenses[index]; + final packageName = mergedLicense.packageName; - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - title: Text( - packageName, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - subtitle: mergedLicense.licenseEntries.length > 1 - ? Text(t.licenses.licensesCount(count: mergedLicense.licenseEntries.length)) - : null, - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showLicenseDetail(mergedLicense), - ), - ); - }, childCount: _mergedLicenses.length), - ), - ), - ], + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text( + packageName, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + subtitle: mergedLicense.licenseEntries.length > 1 + ? Text(t.licenses.licensesCount(count: mergedLicense.licenseEntries.length)) + : null, + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => _showLicenseDetail(context, mergedLicense), + ), + ); + }, childCount: mergedLicenses.length), + ), + ), + ], + ); + }, ); } - void _showLicenseDetail(MergedLicenseEntry mergedLicense) { + void _showLicenseDetail(BuildContext context, MergedLicenseEntry mergedLicense) { Navigator.push( context, MaterialPageRoute(builder: (context) => _LicenseDetailScreen(mergedLicense: mergedLicense)), diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index ca57632b..f3c82f67 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -8,8 +8,10 @@ import '../../i18n/strings.g.dart'; import '../../models/mpv_config_models.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; +import '../../mixins/settings_effect_mixin.dart'; import '../../services/settings_service.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/settings_builder.dart'; class MpvConfigScreen extends StatefulWidget { const MpvConfigScreen({super.key}); @@ -18,20 +20,23 @@ class MpvConfigScreen extends StatefulWidget { State createState() => _MpvConfigScreenState(); } -class _MpvConfigScreenState extends State { - late SettingsService _settingsService; - bool _isLoading = true; +class _MpvConfigScreenState extends State with SettingsEffectMixin { + SettingsService get _settingsService => SettingsService.instanceOrNull!; - late TextEditingController _textController; + late final TextEditingController _textController; final _savePresetFocusNode = FocusNode(); final _textFieldFocusNode = FocusNode(); - List _presets = []; @override void initState() { super.initState(); - _textController = TextEditingController(); - _loadSettings(); + _textController = TextEditingController(text: _settingsService.read(SettingsService.mpvConfigText)); + // Sync the editor when the pref is mutated externally (e.g. loadMpvPreset). + // Skip when the listener fires for the same value the controller already + // holds — avoids fighting user-typed text mid-edit. + bindEffect(SettingsService.mpvConfigText, (v) { + if (_textController.text != v) _textController.text = v; + }, fireImmediately: false); } @override @@ -42,17 +47,6 @@ class _MpvConfigScreenState extends State { super.dispose(); } - Future _loadSettings() async { - _settingsService = await SettingsService.getInstance(); - - if (!mounted) return; - setState(() { - _textController.text = _settingsService.read(SettingsService.mpvConfigText); - _presets = _settingsService.read(SettingsService.mpvPresets); - _isLoading = false; - }); - } - Future _saveText() async { await _settingsService.write(SettingsService.mpvConfigText, _textController.text); } @@ -69,27 +63,14 @@ class _MpvConfigScreenState extends State { if (name != null && name.trim().isNotEmpty) { await _settingsService.saveMpvPreset(name.trim(), _textController.text); - if (!mounted) return; - setState(() { - _presets = _settingsService.read(SettingsService.mpvPresets); - }); - - if (mounted) { - showSuccessSnackBar(context, t.mpvConfig.presetSaved); - } + if (mounted) showSuccessSnackBar(context, t.mpvConfig.presetSaved); } } Future _loadPreset(MpvPreset preset) async { await _settingsService.loadMpvPreset(preset.name); - if (!mounted) return; - setState(() { - _textController.text = _settingsService.read(SettingsService.mpvConfigText); - }); - - if (mounted) { - showAppSnackBar(context, t.mpvConfig.presetLoaded); - } + // Controller text is updated reactively via the bindEffect above. + if (mounted) showAppSnackBar(context, t.mpvConfig.presetLoaded); } Future _deletePreset(MpvPreset preset) async { @@ -98,18 +79,9 @@ class _MpvConfigScreenState extends State { title: t.mpvConfig.deletePreset, message: t.mpvConfig.confirmDeletePreset, ); - - if (confirmed) { - await _settingsService.deleteMpvPreset(preset.name); - if (!mounted) return; - setState(() { - _presets = _settingsService.read(SettingsService.mpvPresets); - }); - - if (mounted) { - showSuccessSnackBar(context, t.mpvConfig.presetDeleted); - } - } + if (!confirmed) return; + await _settingsService.deleteMpvPreset(preset.name); + if (mounted) showSuccessSnackBar(context, t.mpvConfig.presetDeleted); } @override @@ -128,21 +100,19 @@ class _MpvConfigScreenState extends State { }, child: FocusedScrollScaffold( title: Text(t.screens.mpvConfig), - slivers: _isLoading - ? [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))] - : [ - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - _buildConfigEditor(), - const SizedBox(height: 16), - _buildPresetsCard(), - const SizedBox(height: 24), - ]), - ), - ), - ], + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + _buildConfigEditor(), + const SizedBox(height: 16), + _buildPresetsCard(), + const SizedBox(height: 24), + ]), + ), + ), + ], ), ); } @@ -210,57 +180,60 @@ class _MpvConfigScreenState extends State { } Widget _buildPresetsCard() { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.mpvConfig.presets, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ListTile( - focusNode: _savePresetFocusNode, - leading: const AppIcon(Symbols.save_rounded, fill: 1), - title: Text(t.mpvConfig.saveAsPreset), - enabled: _textController.text.trim().isNotEmpty, - onTap: _textController.text.trim().isNotEmpty ? _showSavePresetDialog : null, - ), - if (_presets.isNotEmpty) ...[ - const Divider(), - ..._presets.map( - (preset) => ListTile( - leading: const AppIcon(Symbols.folder_rounded, fill: 1), - title: Text(preset.name), - trailing: PopupMenuButton( - onSelected: (value) { - if (value == 'load') { - _loadPreset(preset); - } else if (value == 'delete') { - _deletePreset(preset); - } - }, - itemBuilder: (context) => [ - PopupMenuItem(value: 'load', child: Text(t.mpvConfig.loadPreset)), - PopupMenuItem(value: 'delete', child: Text(t.mpvConfig.deletePreset)), - ], - ), - onTap: () => _loadPreset(preset), - ), - ), - ] else + return SettingValueBuilder>( + pref: SettingsService.mpvPresets, + builder: (context, presets, _) => Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), + padding: const EdgeInsets.all(16), child: Text( - t.mpvConfig.noPresets, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + t.mpvConfig.presets, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), - ], + ListTile( + focusNode: _savePresetFocusNode, + leading: const AppIcon(Symbols.save_rounded, fill: 1), + title: Text(t.mpvConfig.saveAsPreset), + enabled: _textController.text.trim().isNotEmpty, + onTap: _textController.text.trim().isNotEmpty ? _showSavePresetDialog : null, + ), + if (presets.isNotEmpty) ...[ + const Divider(), + ...presets.map( + (preset) => ListTile( + leading: const AppIcon(Symbols.folder_rounded, fill: 1), + title: Text(preset.name), + trailing: PopupMenuButton( + onSelected: (value) { + if (value == 'load') { + _loadPreset(preset); + } else if (value == 'delete') { + _deletePreset(preset); + } + }, + itemBuilder: (context) => [ + PopupMenuItem(value: 'load', child: Text(t.mpvConfig.loadPreset)), + PopupMenuItem(value: 'delete', child: Text(t.mpvConfig.deletePreset)), + ], + ), + onTap: () => _loadPreset(preset), + ), + ), + ] else + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), + child: Text( + t.mpvConfig.noPresets, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ], + ), ), ); } diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index d37c4976..65ea44c6 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -9,11 +9,12 @@ import '../../mpv/player/platform/player_android.dart'; import '../../utils/quality_preset_labels.dart'; import '../../services/discord_rpc_service.dart'; import '../../services/keyboard_shortcuts_service.dart'; -import '../../services/settings_service.dart' as settings; +import '../../services/settings_service.dart'; import '../../utils/platform_detector.dart'; import '../../utils/snackbar_helper.dart'; -import '../../widgets/app_icon.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/setting_tile.dart'; +import '../../widgets/settings_builder.dart'; import '../../widgets/settings_section.dart'; import 'external_player_screen.dart'; import 'mpv_config_screen.dart'; @@ -28,94 +29,20 @@ class PlaybackSettingsScreen extends StatefulWidget { } class _PlaybackSettingsScreenState extends State { - late settings.SettingsService _settingsService; KeyboardShortcutsService? _keyboardService; - bool _isLoading = true; - - bool _enableHardwareDecoding = true; - int _bufferSize = 0; - int _seekTimeSmall = 10; - int _seekTimeLarge = 30; - int _rewindOnResume = 0; - int _sleepTimerDuration = 30; - bool _rememberTrackSelections = true; - bool _clickVideoTogglesPlayback = false; - bool _autoSkipIntro = false; - bool _autoSkipCredits = false; - bool _forceSkipMarkerFallback = false; - int _autoSkipDelay = 5; - String _introPattern = settings.SettingsService.defaultIntroPattern; - String _creditsPattern = settings.SettingsService.defaultCreditsPattern; - int _maxVolume = 100; - bool _enableDiscordRPC = false; - bool _enableCompanionRemoteServer = false; - bool _autoPip = true; - bool _matchContentFrameRate = false; - bool _matchRefreshRate = false; - bool _matchDynamicRange = false; - int _displaySwitchDelay = 0; - bool _tunneledPlayback = true; - bool _useExoPlayer = true; - bool _useExternalPlayer = false; - String _selectedExternalPlayerName = ''; - TranscodeQualityPreset _defaultQualityPreset = TranscodeQualityPreset.original; @override void initState() { super.initState(); - _loadSettings(); - } - - Future _loadSettings() async { - _settingsService = await settings.SettingsService.getInstance(); if (KeyboardShortcutsService.isPlatformSupported()) { - _keyboardService = await KeyboardShortcutsService.getInstance(); + KeyboardShortcutsService.getInstance().then((s) { + if (mounted) _keyboardService = s; + }); } - - if (!mounted) return; - setState(() { - _enableHardwareDecoding = _settingsService.read(settings.SettingsService.enableHardwareDecoding); - _bufferSize = _settingsService.read(settings.SettingsService.bufferSize); - _seekTimeSmall = _settingsService.read(settings.SettingsService.seekTimeSmall); - _seekTimeLarge = _settingsService.read(settings.SettingsService.seekTimeLarge); - _rewindOnResume = _settingsService.read(settings.SettingsService.rewindOnResume); - _sleepTimerDuration = _settingsService.read(settings.SettingsService.sleepTimerDuration); - _rememberTrackSelections = _settingsService.read(settings.SettingsService.rememberTrackSelections); - _clickVideoTogglesPlayback = _settingsService.read(settings.SettingsService.clickVideoTogglesPlayback); - _autoSkipIntro = _settingsService.read(settings.SettingsService.autoSkipIntro); - _autoSkipCredits = _settingsService.read(settings.SettingsService.autoSkipCredits); - _forceSkipMarkerFallback = _settingsService.read(settings.SettingsService.forceSkipMarkerFallback); - _autoSkipDelay = _settingsService.read(settings.SettingsService.autoSkipDelay); - _introPattern = _settingsService.read(settings.SettingsService.introPattern); - _creditsPattern = _settingsService.read(settings.SettingsService.creditsPattern); - _maxVolume = _settingsService.read(settings.SettingsService.maxVolume); - _enableDiscordRPC = _settingsService.read(settings.SettingsService.enableDiscordRPC); - _enableCompanionRemoteServer = _settingsService.read(settings.SettingsService.enableCompanionRemoteServer); - _autoPip = _settingsService.read(settings.SettingsService.autoPip); - _matchContentFrameRate = _settingsService.read(settings.SettingsService.matchContentFrameRate); - _matchRefreshRate = _settingsService.read(settings.SettingsService.matchRefreshRate); - _matchDynamicRange = _settingsService.read(settings.SettingsService.matchDynamicRange); - _displaySwitchDelay = _settingsService.read(settings.SettingsService.displaySwitchDelay); - _tunneledPlayback = _settingsService.read(settings.SettingsService.tunneledPlayback); - _useExoPlayer = _settingsService.read(settings.SettingsService.useExoPlayer); - _useExternalPlayer = _settingsService.read(settings.SettingsService.useExternalPlayer); - _selectedExternalPlayerName = _settingsService.read(settings.SettingsService.selectedExternalPlayer).name; - _defaultQualityPreset = TranscodeQualityPreset.fromStorage( - _settingsService.read(settings.SettingsService.defaultQualityPreset), - ); - _isLoading = false; - }); } @override Widget build(BuildContext context) { - if (_isLoading) { - return FocusedScrollScaffold( - title: Text(t.settings.videoPlayback), - slivers: [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], - ); - } - final isMobile = PlatformDetector.isMobile(context); return FocusedScrollScaffold( @@ -124,44 +51,154 @@ class _PlaybackSettingsScreenState extends State { SliverList( delegate: SliverChildListDelegate([ SettingsSectionHeader(t.settings.player), - if (Platform.isAndroid) _buildPlayerBackendSelector(), - _buildExternalPlayerTile(), - _buildHardwareDecoding(), - if ((Platform.isAndroid && !PlatformDetector.isTV()) || Platform.isIOS || Platform.isMacOS) _buildAutoPip(), - if (Platform.isAndroid) _buildMatchContentFrameRate(), - if (Platform.isWindows) _buildMatchRefreshRate(), - if (Platform.isWindows) _buildMatchDynamicRange(), - if ((Platform.isWindows && (_matchRefreshRate || _matchDynamicRange)) || - (Platform.isAndroid && _matchContentFrameRate)) - _buildDisplaySwitchDelay(), - if (Platform.isAndroid && _useExoPlayer) _buildTunneledPlayback(), - _buildBufferSizeSelector(), - _buildDefaultQualityTile(), + 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), - _buildSubtitleStylingTile(), - if (!Platform.isAndroid || !_useExoPlayer) _buildMpvConfigTile(), + SettingNavigationTile( + icon: Symbols.subtitles_rounded, + title: t.settings.subtitleStyling, + subtitle: t.settings.subtitleStylingDescription, + destinationBuilder: (_) => const SubtitleStylingScreen(), + ), + _mpvConfigTile(), SettingsSectionHeader(t.settings.seekAndTiming), - _buildSmallSkipDuration(), - _buildLargeSkipDuration(), - _buildRewindOnResume(), - _buildDefaultSleepTimer(), - _buildMaxVolume(), + 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) _buildDiscordRPC(), - if (PlatformDetector.shouldActAsRemoteHost(context)) _buildCompanionRemoteServer(), - _buildRememberTrackSelections(), - if (!isMobile) _buildClickVideoTogglesPlayback(), + 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), - _buildAutoSkipIntro(), - _buildAutoSkipCredits(), - _buildForceSkipMarkerFallback(), - _buildAutoSkipDelay(), - _buildIntroPattern(), - _buildCreditsPattern(), + 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), ]), ), @@ -169,490 +206,152 @@ class _PlaybackSettingsScreenState extends State { ); } - Widget _buildPlayerBackendSelector() { - return SegmentedSetting( - icon: Symbols.play_circle_rounded, - title: t.settings.playerBackend, - segments: [ - ButtonSegment(value: true, label: Text(t.settings.exoPlayer)), - ButtonSegment(value: false, label: Text(t.settings.mpv)), - ], - selected: _useExoPlayer, - onChanged: (value) async { - setState(() => _useExoPlayer = value); - await _settingsService.write(settings.SettingsService.useExoPlayer, value); - }, - ); - } + Widget _playerBackendSelector() => SettingSegmentedTile( + pref: SettingsService.useExoPlayer, + icon: Symbols.play_circle_rounded, + title: t.settings.playerBackend, + segments: [ + ButtonSegment(value: true, label: Text(t.settings.exoPlayer)), + ButtonSegment(value: false, label: Text(t.settings.mpv)), + ], + decode: (s) => s, + encode: (s) => s, + ); - Widget _buildExternalPlayerTile() { - return ListTile( - leading: const AppIcon(Symbols.open_in_new_rounded, fill: 1), - title: Text(t.externalPlayer.title), - subtitle: Text(_useExternalPlayer ? _selectedExternalPlayerName : t.externalPlayer.off), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () async { - await Navigator.push(context, MaterialPageRoute(builder: (context) => const ExternalPlayerScreen())); - final s = await settings.SettingsService.getInstance(); - if (!mounted) return; - setState(() { - _useExternalPlayer = s.read(settings.SettingsService.useExternalPlayer); - _selectedExternalPlayerName = s.read(settings.SettingsService.selectedExternalPlayer).name; - }); - }, - ); - } + Widget _externalPlayerTile() => SettingsBuilder( + prefs: [SettingsService.useExternalPlayer, SettingsService.selectedExternalPlayer], + builder: (context) { + final svc = SettingsService.instanceOrNull!; + final useExt = svc.read(SettingsService.useExternalPlayer); + final player = svc.read(SettingsService.selectedExternalPlayer); + return SettingNavigationTile( + icon: Symbols.open_in_new_rounded, + title: t.externalPlayer.title, + subtitle: useExt ? player.name : t.externalPlayer.off, + destinationBuilder: (_) => const ExternalPlayerScreen(), + ); + }, + ); - Widget _buildHardwareDecoding() { - return SwitchListTile( - secondary: const AppIcon(Symbols.hardware_rounded, fill: 1), - title: Text(t.settings.hardwareDecoding), - subtitle: Text(t.settings.hardwareDecodingDescription), - value: _enableHardwareDecoding, - onChanged: (value) async { - setState(() => _enableHardwareDecoding = value); - await _settingsService.write(settings.SettingsService.enableHardwareDecoding, value); - }, - ); - } + Widget _hardwareDecodingTile() => SettingSwitchTile( + pref: SettingsService.enableHardwareDecoding, + icon: Symbols.hardware_rounded, + title: t.settings.hardwareDecoding, + subtitle: t.settings.hardwareDecodingDescription, + ); - Widget _buildAutoPip() { - return SwitchListTile( - secondary: const AppIcon(Symbols.picture_in_picture_alt_rounded, fill: 1), - title: Text(t.settings.autoPip), - subtitle: Text(t.settings.autoPipDescription), - value: _autoPip, - onChanged: (value) async { - setState(() => _autoPip = value); - await _settingsService.write(settings.SettingsService.autoPip, value); - }, - ); - } + Widget _autoPipTile() => SettingSwitchTile( + pref: SettingsService.autoPip, + icon: Symbols.picture_in_picture_alt_rounded, + title: t.settings.autoPip, + subtitle: t.settings.autoPipDescription, + ); - Widget _buildMatchContentFrameRate() { - return SwitchListTile( - secondary: const AppIcon(Symbols.display_settings_rounded, fill: 1), - title: Text(t.settings.matchContentFrameRate), - subtitle: Text(t.settings.matchContentFrameRateDescription), - value: _matchContentFrameRate, - onChanged: (value) async { - setState(() => _matchContentFrameRate = value); - await _settingsService.write(settings.SettingsService.matchContentFrameRate, value); - }, - ); - } + Widget _matchContentFrameRateTile() => SettingSwitchTile( + pref: SettingsService.matchContentFrameRate, + icon: Symbols.display_settings_rounded, + title: t.settings.matchContentFrameRate, + subtitle: t.settings.matchContentFrameRateDescription, + ); - Widget _buildMatchRefreshRate() { - return SwitchListTile( - secondary: const AppIcon(Symbols.display_settings_rounded, fill: 1), - title: Text(t.settings.matchRefreshRate), - subtitle: Text(t.settings.matchRefreshRateDescription), - value: _matchRefreshRate, - onChanged: (value) async { - setState(() => _matchRefreshRate = value); - await _settingsService.write(settings.SettingsService.matchRefreshRate, value); - }, - ); - } + Widget _matchRefreshRateTile() => SettingSwitchTile( + pref: SettingsService.matchRefreshRate, + icon: Symbols.display_settings_rounded, + title: t.settings.matchRefreshRate, + subtitle: t.settings.matchRefreshRateDescription, + ); - Widget _buildMatchDynamicRange() { - return SwitchListTile( - secondary: const AppIcon(Symbols.hdr_on_rounded, fill: 1), - title: Text(t.settings.matchDynamicRange), - subtitle: Text(t.settings.matchDynamicRangeDescription), - value: _matchDynamicRange, - onChanged: (value) async { - setState(() => _matchDynamicRange = value); - await _settingsService.write(settings.SettingsService.matchDynamicRange, value); - }, - ); - } + Widget _matchDynamicRangeTile() => SettingSwitchTile( + pref: SettingsService.matchDynamicRange, + icon: Symbols.hdr_on_rounded, + title: t.settings.matchDynamicRange, + subtitle: t.settings.matchDynamicRangeDescription, + ); - Widget _buildDisplaySwitchDelay() { - return ListTile( - leading: const AppIcon(Symbols.timer_rounded, fill: 1), - title: Text(t.settings.displaySwitchDelay), - subtitle: Text(t.settings.secondsUnit(seconds: _displaySwitchDelay.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, + Widget _displaySwitchDelayTile() => SettingsBuilder( + prefs: const [ + SettingsService.matchRefreshRate, + SettingsService.matchDynamicRange, + SettingsService.matchContentFrameRate, + ], + builder: (context) { + final svc = SettingsService.instanceOrNull!; + final shouldShow = + (Platform.isWindows && + (svc.read(SettingsService.matchRefreshRate) || svc.read(SettingsService.matchDynamicRange))) || + (Platform.isAndroid && svc.read(SettingsService.matchContentFrameRate)); + if (!shouldShow) return const SizedBox.shrink(); + return SettingNumberTile( + pref: SettingsService.displaySwitchDelay, + icon: Symbols.timer_rounded, title: t.settings.displaySwitchDelay, + subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), labelText: t.settings.secondsLabel, suffixText: t.settings.secondsShort, min: 0, max: 10, - currentValue: _displaySwitchDelay, - onSave: (value) async { - setState(() => _displaySwitchDelay = value); - await _settingsService.write(settings.SettingsService.displaySwitchDelay, value); - }, - ), - ); - } + ); + }, + ); - Widget _buildTunneledPlayback() { - return SwitchListTile( - secondary: const AppIcon(Symbols.tv_options_input_settings_rounded, fill: 1), - title: Text(t.settings.tunneledPlayback), - subtitle: Text(t.settings.tunneledPlaybackDescription), - value: _tunneledPlayback, - onChanged: (value) async { - setState(() => _tunneledPlayback = value); - await _settingsService.write(settings.SettingsService.tunneledPlayback, value); - }, - ); - } + Widget _tunneledPlaybackTile() => SettingValueBuilder( + pref: SettingsService.useExoPlayer, + builder: (_, useExo, _) { + if (!Platform.isAndroid || !useExo) return const SizedBox.shrink(); + return SettingSwitchTile( + pref: SettingsService.tunneledPlayback, + icon: Symbols.tv_options_input_settings_rounded, + title: t.settings.tunneledPlayback, + subtitle: t.settings.tunneledPlaybackDescription, + ); + }, + ); - Widget _buildDefaultQualityTile() { - return ListTile( - leading: const AppIcon(Symbols.high_quality_rounded, fill: 1), - title: Text(t.settings.defaultQualityTitle), - subtitle: Text(qualityPresetLabel(_defaultQualityPreset)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () async { - final value = await showSelectionDialog( - context: context, - title: t.settings.defaultQualityTitle, - options: TranscodeQualityPreset.displayOrder - .map((p) => DialogOption(value: p, title: qualityPresetLabel(p))) - .toList(), - currentValue: _defaultQualityPreset, - ); - if (value != null) { - setState(() { - _defaultQualityPreset = value; - _settingsService.write(settings.SettingsService.defaultQualityPreset, value.storageKey); - }); - } - }, - ); - } - - Widget _buildBufferSizeSelector() { - return ListTile( - leading: const AppIcon(Symbols.memory_rounded, fill: 1), - title: Text(t.settings.bufferSize), - subtitle: Text( - _bufferSize == 0 ? t.settings.bufferSizeAuto : t.settings.bufferSizeMB(size: _bufferSize.toString()), - ), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () async { - final bufferOptions = [0, 64, 128, 256, 512, 1024]; - final value = await showSelectionDialog( - context: context, - title: t.settings.bufferSize, - options: bufferOptions - .map((size) => DialogOption(value: size, title: size == 0 ? t.settings.bufferSizeAuto : '${size}MB')) - .toList(), - currentValue: _bufferSize, - ); - if (value != null) { - setState(() { - _bufferSize = value; - _settingsService.write(settings.SettingsService.bufferSize, value); - }); - if (Platform.isAndroid && value > 0) { - final heapMB = await PlayerAndroid.getHeapSize(); - if (heapMB > 0 && value > heapMB ~/ 4 && mounted) { - showAppSnackBar(context, t.settings.bufferSizeWarning(heap: heapMB.toString(), size: value.toString())); - } + Widget _bufferSizeTile() { + final bufferOptions = const [0, 64, 128, 256, 512, 1024]; + return SettingSelectionTile( + pref: SettingsService.bufferSize, + icon: Symbols.memory_rounded, + title: t.settings.bufferSize, + subtitleBuilder: (v) => v == 0 ? t.settings.bufferSizeAuto : t.settings.bufferSizeMB(size: v.toString()), + options: bufferOptions + .map((s) => DialogOption(value: s, title: s == 0 ? t.settings.bufferSizeAuto : '${s}MB')) + .toList(), + decode: (s) => s, + encode: (s) => s, + onAfterWrite: (value) async { + if (Platform.isAndroid && value > 0) { + final heapMB = await PlayerAndroid.getHeapSize(); + if (heapMB > 0 && value > heapMB ~/ 4 && mounted) { + showAppSnackBar(context, t.settings.bufferSizeWarning(heap: heapMB.toString(), size: value.toString())); } } }, ); } - Widget _buildSubtitleStylingTile() { - return ListTile( - leading: const AppIcon(Symbols.subtitles_rounded, fill: 1), - title: Text(t.settings.subtitleStyling), - subtitle: Text(t.settings.subtitleStylingDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const SubtitleStylingScreen())); - }, - ); - } + Widget _defaultQualityTile() => SettingSelectionTile( + pref: SettingsService.defaultQualityPreset, + icon: Symbols.high_quality_rounded, + title: t.settings.defaultQualityTitle, + subtitleBuilder: qualityPresetLabel, + options: TranscodeQualityPreset.displayOrder + .map((p) => DialogOption(value: p, title: qualityPresetLabel(p))) + .toList(), + decode: (p) => p, + encode: (p) => p, + ); - Widget _buildMpvConfigTile() { - return ListTile( - leading: const AppIcon(Symbols.tune_rounded, fill: 1), - title: Text(t.mpvConfig.title), - subtitle: Text(t.mpvConfig.description), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const MpvConfigScreen())); - }, - ); - } - - Widget _buildSmallSkipDuration() { - return ListTile( - leading: const AppIcon(Symbols.replay_10_rounded, fill: 1), - title: Text(t.settings.smallSkipDuration), - subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeSmall.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: t.settings.smallSkipDuration, - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 120, - currentValue: _seekTimeSmall, - onSave: (value) async { - setState(() { - _seekTimeSmall = value; - _settingsService.write(settings.SettingsService.seekTimeSmall, value); - }); - await _keyboardService?.refreshFromStorage(); - }, - ), - ); - } - - Widget _buildLargeSkipDuration() { - return ListTile( - leading: const AppIcon(Symbols.replay_30_rounded, fill: 1), - title: Text(t.settings.largeSkipDuration), - subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeLarge.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: t.settings.largeSkipDuration, - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 120, - currentValue: _seekTimeLarge, - onSave: (value) async { - setState(() { - _seekTimeLarge = value; - _settingsService.write(settings.SettingsService.seekTimeLarge, value); - }); - await _keyboardService?.refreshFromStorage(); - }, - ), - ); - } - - Widget _buildRewindOnResume() { - return ListTile( - leading: const AppIcon(Symbols.replay_rounded, fill: 1), - title: Text(t.settings.rewindOnResume), - subtitle: Text(t.settings.secondsUnit(seconds: _rewindOnResume.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: t.settings.rewindOnResume, - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 0, - max: 10, - currentValue: _rewindOnResume, - onSave: (value) async { - setState(() { - _rewindOnResume = value; - _settingsService.write(settings.SettingsService.rewindOnResume, value); - }); - }, - ), - ); - } - - Widget _buildDefaultSleepTimer() { - return ListTile( - leading: const AppIcon(Symbols.bedtime_rounded, fill: 1), - title: Text(t.settings.defaultSleepTimer), - subtitle: Text(t.settings.minutesUnit(minutes: _sleepTimerDuration.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: t.settings.defaultSleepTimer, - labelText: t.settings.minutesLabel, - suffixText: t.settings.minutesShort, - min: 5, - max: 240, - currentValue: _sleepTimerDuration, - onSave: (value) async { - setState(() => _sleepTimerDuration = value); - await _settingsService.write(settings.SettingsService.sleepTimerDuration, value); - }, - ), - ); - } - - Widget _buildMaxVolume() { - return ListTile( - leading: const AppIcon(Symbols.volume_up_rounded, fill: 1), - title: Text(t.settings.maxVolume), - subtitle: Text(t.settings.maxVolumePercent(percent: _maxVolume.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: t.settings.maxVolume, - labelText: t.settings.maxVolumeDescription, - suffixText: '%', - min: 100, - max: 300, - currentValue: _maxVolume, - onSave: (value) async { - setState(() => _maxVolume = value); - await _settingsService.write(settings.SettingsService.maxVolume, value); - }, - ), - ); - } - - Widget _buildDiscordRPC() { - return SwitchListTile( - secondary: const AppIcon(Symbols.chat_rounded, fill: 1), - title: Text(t.settings.discordRichPresence), - subtitle: Text(t.settings.discordRichPresenceDescription), - value: _enableDiscordRPC, - onChanged: (value) async { - setState(() => _enableDiscordRPC = value); - await _settingsService.write(settings.SettingsService.enableDiscordRPC, value); - await DiscordRPCService.instance.setEnabled(value); - }, - ); - } - - Widget _buildCompanionRemoteServer() { - return SwitchListTile( - secondary: const AppIcon(Symbols.phone_android_rounded, fill: 1), - title: Text(t.settings.companionRemoteServer), - subtitle: Text(t.settings.companionRemoteServerDescription), - value: _enableCompanionRemoteServer, - onChanged: (value) async { - setState(() => _enableCompanionRemoteServer = value); - await _settingsService.write(settings.SettingsService.enableCompanionRemoteServer, value); - }, - ); - } - - Widget _buildRememberTrackSelections() { - return SwitchListTile( - secondary: const AppIcon(Symbols.bookmark_rounded, fill: 1), - title: Text(t.settings.rememberTrackSelections), - subtitle: Text(t.settings.rememberTrackSelectionsDescription), - value: _rememberTrackSelections, - onChanged: (value) async { - setState(() => _rememberTrackSelections = value); - await _settingsService.write(settings.SettingsService.rememberTrackSelections, value); - }, - ); - } - - Widget _buildClickVideoTogglesPlayback() { - return SwitchListTile( - secondary: const AppIcon(Symbols.play_pause_rounded, fill: 1), - title: Text(t.settings.clickVideoTogglesPlayback), - subtitle: Text(t.settings.clickVideoTogglesPlaybackDescription), - value: _clickVideoTogglesPlayback, - onChanged: (value) async { - setState(() => _clickVideoTogglesPlayback = value); - await _settingsService.write(settings.SettingsService.clickVideoTogglesPlayback, value); - }, - ); - } - - Widget _buildAutoSkipIntro() { - return SwitchListTile( - secondary: const AppIcon(Symbols.fast_forward_rounded, fill: 1), - title: Text(t.settings.autoSkipIntro), - subtitle: Text(t.settings.autoSkipIntroDescription), - value: _autoSkipIntro, - onChanged: (value) async { - setState(() => _autoSkipIntro = value); - await _settingsService.write(settings.SettingsService.autoSkipIntro, value); - }, - ); - } - - Widget _buildAutoSkipCredits() { - return SwitchListTile( - secondary: const AppIcon(Symbols.skip_next_rounded, fill: 1), - title: Text(t.settings.autoSkipCredits), - subtitle: Text(t.settings.autoSkipCreditsDescription), - value: _autoSkipCredits, - onChanged: (value) async { - setState(() => _autoSkipCredits = value); - await _settingsService.write(settings.SettingsService.autoSkipCredits, value); - }, - ); - } - - Widget _buildForceSkipMarkerFallback() { - return SwitchListTile( - secondary: const AppIcon(Symbols.tune_rounded, fill: 1), - title: Text(t.settings.forceSkipMarkerFallback), - subtitle: Text(t.settings.forceSkipMarkerFallbackDescription), - value: _forceSkipMarkerFallback, - onChanged: (value) async { - setState(() => _forceSkipMarkerFallback = value); - await _settingsService.write(settings.SettingsService.forceSkipMarkerFallback, value); - }, - ); - } - - Widget _buildAutoSkipDelay() { - return ListTile( - leading: const AppIcon(Symbols.timer_rounded, fill: 1), - title: Text(t.settings.autoSkipDelay), - subtitle: Text(t.settings.autoSkipDelayDescription(seconds: _autoSkipDelay.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: t.settings.autoSkipDelay, - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 30, - currentValue: _autoSkipDelay, - onSave: (value) async { - setState(() => _autoSkipDelay = value); - await _settingsService.write(settings.SettingsService.autoSkipDelay, value); - }, - ), - ); - } - - Widget _buildIntroPattern() { - return ListTile( - leading: const AppIcon(Symbols.match_case_rounded, fill: 1), - title: Text(t.settings.introPattern), - subtitle: Text(t.settings.introPatternDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showRegexInputDialog( - context: context, - title: t.settings.introPattern, - currentValue: _introPattern, - defaultValue: settings.SettingsService.defaultIntroPattern, - onSave: (value) async { - setState(() => _introPattern = value); - await _settingsService.write(settings.SettingsService.introPattern, value); - }, - ), - ); - } - - Widget _buildCreditsPattern() { - return ListTile( - leading: const AppIcon(Symbols.match_case_rounded, fill: 1), - title: Text(t.settings.creditsPattern), - subtitle: Text(t.settings.creditsPatternDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showRegexInputDialog( - context: context, - title: t.settings.creditsPattern, - currentValue: _creditsPattern, - defaultValue: settings.SettingsService.defaultCreditsPattern, - onSave: (value) async { - setState(() => _creditsPattern = value); - await _settingsService.write(settings.SettingsService.creditsPattern, value); - }, - ), - ); - } + Widget _mpvConfigTile() => SettingValueBuilder( + pref: SettingsService.useExoPlayer, + builder: (_, useExo, _) { + if (Platform.isAndroid && useExo) return const SizedBox.shrink(); + return SettingNavigationTile( + icon: Symbols.tune_rounded, + title: t.mpvConfig.title, + subtitle: t.mpvConfig.description, + destinationBuilder: (_) => const MpvConfigScreen(), + ); + }, + ); } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 1fea9fbf..c0beda4a 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -21,7 +21,6 @@ import '../../services/download_storage_service.dart'; import '../../services/file_picker_service.dart'; import '../../services/saf_storage_service.dart'; import '../../services/settings_export_service.dart'; -import '../../providers/settings_provider.dart'; import '../../providers/theme_provider.dart'; import '../../providers/trackers_provider.dart'; import '../../providers/trakt_account_provider.dart'; @@ -34,6 +33,8 @@ import '../../utils/platform_detector.dart'; import '../../utils/update_dialog.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/dialog_action_button.dart'; +import '../../widgets/setting_tile.dart'; +import '../../widgets/settings_builder.dart'; import '../../widgets/settings_section.dart'; import '../../profiles/active_profile_provider.dart'; import '../../profiles/profile.dart'; @@ -56,7 +57,6 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State with FocusableTab { - late settings.SettingsService _settingsService; late final FocusMemoryTracker _focusTracker; // Focus tracking keys @@ -83,15 +83,6 @@ class _SettingsScreenState extends State with FocusableTab { KeyboardShortcutsService? _keyboardService; late final bool _keyboardShortcutsSupported = KeyboardShortcutsService.isPlatformSupported(); - bool _isLoading = true; - - bool _crashReporting = true; - bool _enableDebugLogging = false; - bool _downloadOnWifiOnly = false; - bool _autoRemoveWatchedDownloads = false; - bool _autoCheckUpdatesOnStartup = true; - bool _videoPlayerNavigationEnabled = false; - String? _customRelayUrl; // Update checking state bool _isCheckingForUpdate = false; @@ -107,7 +98,11 @@ class _SettingsScreenState extends State with FocusableTab { }, debugLabelPrefix: 'settings', ); - _loadSettings(); + if (_keyboardShortcutsSupported) { + KeyboardShortcutsService.getInstance().then((s) { + if (mounted) setState(() => _keyboardService = s); + }); + } } @override @@ -135,31 +130,10 @@ class _SettingsScreenState extends State with FocusableTab { return KeyEventResult.ignored; } - Future _loadSettings() async { - _settingsService = await settings.SettingsService.getInstance(); - if (_keyboardShortcutsSupported) { - _keyboardService = await KeyboardShortcutsService.getInstance(); - } - - if (!mounted) return; - setState(() { - _crashReporting = _settingsService.read(settings.SettingsService.crashReporting); - _enableDebugLogging = _settingsService.read(settings.SettingsService.enableDebugLogging); - _downloadOnWifiOnly = _settingsService.read(settings.SettingsService.downloadOnWifiOnly); - _autoRemoveWatchedDownloads = _settingsService.read(settings.SettingsService.autoRemoveWatchedDownloads); - _autoCheckUpdatesOnStartup = _settingsService.read(settings.SettingsService.autoCheckUpdatesOnStartup); - _videoPlayerNavigationEnabled = _settingsService.read(settings.SettingsService.videoPlayerNavigationEnabled); - _customRelayUrl = _settingsService.read(settings.SettingsService.customRelayUrl); - _isLoading = false; - }); - } + settings.SettingsService get _settingsService => settings.SettingsService.instanceOrNull!; @override Widget build(BuildContext context) { - if (_isLoading) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); - } - return Scaffold( body: Focus( onKeyEvent: _handleKeyEvent, @@ -191,15 +165,12 @@ class _SettingsScreenState extends State with FocusableTab { if (!PlatformDetector.isTV()) _buildBackupSection(), - ListTile( + SettingNavigationTile( focusNode: _focusTracker.get(_kAbout), - leading: const AppIcon(Symbols.info_rounded, fill: 1), - title: Text(t.settings.about), - subtitle: Text(t.settings.aboutDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const AboutScreen())); - }, + icon: Symbols.info_rounded, + title: t.settings.about, + subtitle: t.settings.aboutDescription, + destinationBuilder: (context) => const AboutScreen(), ), const SizedBox(height: 24), ]), @@ -227,34 +198,30 @@ class _SettingsScreenState extends State with FocusableTab { } Widget _buildAppearanceTile() { - return Consumer2( - builder: (context, themeProvider, settingsProvider, child) { - final summary = - '${themeProvider.themeModeDisplayName} · ${t.settings.libraryDensity} ${settingsProvider.libraryDensity}'; - return ListTile( - focusNode: _focusTracker.get(_kAppearance), - leading: const AppIcon(Symbols.palette_rounded, fill: 1), - title: Text(t.settings.appearance), - subtitle: Text(summary), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const AppearanceSettingsScreen())); - }, - ); - }, + return Consumer( + builder: (context, themeProvider, _) => SettingValueBuilder( + pref: settings.SettingsService.libraryDensity, + builder: (context, libraryDensity, _) { + final summary = '${themeProvider.themeModeDisplayName} · ${t.settings.libraryDensity} $libraryDensity'; + return SettingNavigationTile( + focusNode: _focusTracker.get(_kAppearance), + icon: Symbols.palette_rounded, + title: t.settings.appearance, + subtitle: summary, + destinationBuilder: (context) => const AppearanceSettingsScreen(), + ); + }, + ), ); } Widget _buildPlaybackTile() { - return ListTile( + return SettingNavigationTile( focusNode: _focusTracker.get(_kPlayback), - leading: const AppIcon(Symbols.play_circle_rounded, fill: 1), - title: Text(t.settings.videoPlayback), - subtitle: Text(t.settings.videoPlaybackDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const PlaybackSettingsScreen())); - }, + icon: Symbols.play_circle_rounded, + title: t.settings.videoPlayback, + subtitle: t.settings.videoPlaybackDescription, + destinationBuilder: (context) => const PlaybackSettingsScreen(), ); } @@ -268,21 +235,23 @@ class _SettingsScreenState extends State with FocusableTab { if (trackers.isSimklConnected) t.trackers.services.simkl, ]; final subtitle = connectedNames.isEmpty ? t.settings.trackersDescription : connectedNames.join(' · '); - return ListTile( + return SettingNavigationTile( focusNode: _focusTracker.get(_kTrackers), - leading: const AppIcon(Symbols.sync_rounded, fill: 1), - title: Text(t.settings.trackers), - subtitle: Text(subtitle), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => const TrackersSettingsScreen())); - }, + icon: Symbols.sync_rounded, + title: t.settings.trackers, + subtitle: subtitle, + destinationBuilder: (_) => const TrackersSettingsScreen(), ); }, ); } Widget _buildConnectionsSection() { + final active = context.select((p) => p.active); + final subtitle = active == null + ? t.connections.addConnectionSubtitleNoProfile + : t.connections.addConnectionSubtitleScoped(displayName: active.displayName); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -291,22 +260,10 @@ class _SettingsScreenState extends State with FocusableTab { // and each profile's detail screen). The shortcut here just opens // the picker scoped to the active profile so users can add a Plex // account, Jellyfin server, or borrow from another profile. - ListTile( - leading: const AppIcon(Symbols.add_link_rounded, fill: 1), - title: Text(t.connections.addConnection), - subtitle: Builder( - builder: (context) { - // Select only the active profile slice — `context.watch` - // would rebuild on every provider notification. - final active = context.select((p) => p.active); - return Text( - active == null - ? t.connections.addConnectionSubtitleNoProfile - : t.connections.addConnectionSubtitleScoped(displayName: active.displayName), - ); - }, - ), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + SettingNavigationTile( + icon: Symbols.add_link_rounded, + title: t.connections.addConnection, + subtitle: subtitle, onTap: () { final active = context.read().active; Navigator.push(context, MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: active))); @@ -330,14 +287,11 @@ class _SettingsScreenState extends State with FocusableTab { : (activeName != null ? t.profiles.summaryMultipleWithActive(count: count, activeName: activeName) : t.profiles.summaryMultiple(count: count)); - return ListTile( - leading: const AppIcon(Symbols.group_rounded, fill: 1), - title: Text(t.profiles.sectionTitle), - subtitle: Text(subtitle), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => const ProfileSwitchScreen())); - }, + return SettingNavigationTile( + icon: Symbols.group_rounded, + title: t.profiles.sectionTitle, + subtitle: subtitle, + destinationBuilder: (_) => const ProfileSwitchScreen(), ); }, ); @@ -366,27 +320,19 @@ class _SettingsScreenState extends State with FocusableTab { ); }, ), - SwitchListTile( + SettingSwitchTile( focusNode: _focusTracker.get(_kDownloadOnWifiOnly), - secondary: const AppIcon(Symbols.wifi_rounded, fill: 1), - title: Text(t.settings.downloadOnWifiOnly), - subtitle: Text(t.settings.downloadOnWifiOnlyDescription), - value: _downloadOnWifiOnly, - onChanged: (value) async { - setState(() => _downloadOnWifiOnly = value); - await _settingsService.write(settings.SettingsService.downloadOnWifiOnly, value); - }, + pref: settings.SettingsService.downloadOnWifiOnly, + icon: Symbols.wifi_rounded, + title: t.settings.downloadOnWifiOnly, + subtitle: t.settings.downloadOnWifiOnlyDescription, ), - SwitchListTile( + SettingSwitchTile( focusNode: _focusTracker.get(_kAutoRemoveWatchedDownloads), - secondary: const AppIcon(Symbols.delete_sweep_rounded, fill: 1), - title: Text(t.settings.autoRemoveWatchedDownloads), - subtitle: Text(t.settings.autoRemoveWatchedDownloadsDescription), - value: _autoRemoveWatchedDownloads, - onChanged: (value) async { - setState(() => _autoRemoveWatchedDownloads = value); - await _settingsService.write(settings.SettingsService.autoRemoveWatchedDownloads, value); - }, + pref: settings.SettingsService.autoRemoveWatchedDownloads, + icon: Symbols.delete_sweep_rounded, + title: t.settings.autoRemoveWatchedDownloads, + subtitle: t.settings.autoRemoveWatchedDownloadsDescription, ), ], ); @@ -399,12 +345,11 @@ class _SettingsScreenState extends State with FocusableTab { crossAxisAlignment: CrossAxisAlignment.start, children: [ SettingsSectionHeader(t.settings.keyboardShortcuts), - ListTile( + SettingNavigationTile( focusNode: _focusTracker.get(_kVideoPlayerControls), - leading: const AppIcon(Symbols.keyboard_rounded, fill: 1), - title: Text(t.settings.videoPlayerControls), - subtitle: Text(t.settings.keyboardShortcutsDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + icon: Symbols.keyboard_rounded, + title: t.settings.videoPlayerControls, + subtitle: t.settings.keyboardShortcutsDescription, onTap: () { Navigator.push( context, @@ -412,16 +357,12 @@ class _SettingsScreenState extends State with FocusableTab { ); }, ), - SwitchListTile( + SettingSwitchTile( focusNode: _focusTracker.get(_kVideoPlayerNavigation), - secondary: const AppIcon(Symbols.gamepad_rounded, fill: 1), - title: Text(t.settings.videoPlayerNavigation), - subtitle: Text(t.settings.videoPlayerNavigationDescription), - value: _videoPlayerNavigationEnabled, - onChanged: (value) async { - setState(() => _videoPlayerNavigationEnabled = value); - await _settingsService.write(settings.SettingsService.videoPlayerNavigationEnabled, value); - }, + pref: settings.SettingsService.videoPlayerNavigationEnabled, + icon: Symbols.gamepad_rounded, + title: t.settings.videoPlayerNavigation, + subtitle: t.settings.videoPlayerNavigationDescription, ), ], ); @@ -440,37 +381,26 @@ class _SettingsScreenState extends State with FocusableTab { trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showRelayUrlDialog(), ), - SwitchListTile( + SettingSwitchTile( focusNode: _focusTracker.get(_kCrashReporting), - secondary: const AppIcon(Symbols.monitoring_rounded, fill: 1), - title: Text(t.settings.crashReporting), - subtitle: Text(t.settings.crashReportingDescription), - value: _crashReporting, - onChanged: (value) async { - setState(() => _crashReporting = value); - await _settingsService.write(settings.SettingsService.crashReporting, value); - }, + pref: settings.SettingsService.crashReporting, + icon: Symbols.monitoring_rounded, + title: t.settings.crashReporting, + subtitle: t.settings.crashReportingDescription, ), - SwitchListTile( + SettingSwitchTile( focusNode: _focusTracker.get(_kDebugLogging), - secondary: const AppIcon(Symbols.bug_report_rounded, fill: 1), - title: Text(t.settings.debugLogging), - subtitle: Text(t.settings.debugLoggingDescription), - value: _enableDebugLogging, - onChanged: (value) async { - setState(() => _enableDebugLogging = value); - await _settingsService.write(settings.SettingsService.enableDebugLogging, value); - }, + pref: settings.SettingsService.enableDebugLogging, + icon: Symbols.bug_report_rounded, + title: t.settings.debugLogging, + subtitle: t.settings.debugLoggingDescription, ), - ListTile( + SettingNavigationTile( focusNode: _focusTracker.get(_kViewLogs), - leading: const AppIcon(Symbols.article_rounded, fill: 1), - title: Text(t.settings.viewLogs), - subtitle: Text(t.settings.viewLogsDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); - }, + icon: Symbols.article_rounded, + title: t.settings.viewLogs, + subtitle: t.settings.viewLogsDescription, + destinationBuilder: (context) => const LogsScreen(), ), ListTile( focusNode: _focusTracker.get(_kClearCache), @@ -539,19 +469,13 @@ class _SettingsScreenState extends State with FocusableTab { ); } - Widget _buildAutoCheckUpdatesOnStartupTile() { - return SwitchListTile( - focusNode: _focusTracker.get(_kAutoCheckUpdatesOnStartup), - secondary: const AppIcon(Symbols.notifications_active_rounded, fill: 1), - title: Text(t.settings.autoCheckUpdatesOnStartup), - subtitle: Text(t.settings.autoCheckUpdatesOnStartupDescription), - value: _autoCheckUpdatesOnStartup, - onChanged: (value) async { - setState(() => _autoCheckUpdatesOnStartup = value); - await _settingsService.write(settings.SettingsService.autoCheckUpdatesOnStartup, value); - }, - ); - } + Widget _buildAutoCheckUpdatesOnStartupTile() => SettingSwitchTile( + focusNode: _focusTracker.get(_kAutoCheckUpdatesOnStartup), + pref: settings.SettingsService.autoCheckUpdatesOnStartup, + icon: Symbols.notifications_active_rounded, + title: t.settings.autoCheckUpdatesOnStartup, + subtitle: t.settings.autoCheckUpdatesOnStartupDescription, + ); Widget _buildUpdateSection() { if (UpdateService.useNativeUpdater) { @@ -718,7 +642,9 @@ class _SettingsScreenState extends State with FocusableTab { } Future _showRelayUrlDialog() async { - final controller = TextEditingController(text: _customRelayUrl ?? ''); + final controller = TextEditingController( + text: _settingsService.read(settings.SettingsService.customRelayUrl) ?? '', + ); final saveFocusNode = FocusNode(); // try/finally guarantees disposal on every dismissal path (button, // back, tap-outside) without depending on `.then` chaining. @@ -740,7 +666,6 @@ class _SettingsScreenState extends State with FocusableTab { onPressed: () async { controller.clear(); await _settingsService.write(settings.SettingsService.customRelayUrl, null); - if (mounted) setState(() => _customRelayUrl = null); if (dialogContext.mounted) Navigator.pop(dialogContext); }, label: t.settings.resetToDefault, @@ -751,7 +676,6 @@ class _SettingsScreenState extends State with FocusableTab { onPressed: () async { final url = controller.text.trim().isEmpty ? null : controller.text.trim(); await _settingsService.write(settings.SettingsService.customRelayUrl, url); - if (mounted) setState(() => _customRelayUrl = url); if (dialogContext.mounted) Navigator.pop(dialogContext); }, label: t.common.save, @@ -789,10 +713,7 @@ class _SettingsScreenState extends State with FocusableTab { if (!confirmed) return; await _settingsService.resetAllSettings(); await _keyboardService?.resetToDefaults(); - if (mounted) { - showSuccessSnackBar(context, t.settings.resetSettingsSuccess); - unawaited(_loadSettings()); - } + if (mounted) showSuccessSnackBar(context, t.settings.resetSettingsSuccess); } Future _handleExportSettings() async { @@ -823,7 +744,6 @@ class _SettingsScreenState extends State with FocusableTab { // Capture providers before any awaits so we don't reach through `context` // after the widget may have been unmounted. final themeProvider = context.read(); - final settingsProvider = context.read(); final hiddenLibrariesProvider = context.read(); final librariesProvider = context.read(); @@ -832,17 +752,18 @@ class _SettingsScreenState extends State with FocusableTab { if (!mounted) return; if (result == null) return; // user cancelled file picker + // Import wrote directly to SharedPreferences, bypassing `write`. Push + // fresh values into active listenables before providers re-read settings. + _settingsService.refreshListenables(); unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale))); await Future.wait([ themeProvider.reload(), - settingsProvider.reload(), hiddenLibrariesProvider.refresh(), if (_keyboardService != null) _keyboardService!.refreshFromStorage(), ]); unawaited(librariesProvider.refresh()); if (!mounted) return; - unawaited(_loadSettings()); showSuccessSnackBar(context, t.settings.importSettingsSuccess); } on NoUserSignedInException { if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser); diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index 42d8d310..26f9c9a5 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -1,9 +1,11 @@ +import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flutter/material.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; import '../../widgets/dialog_action_button.dart'; import '../../widgets/focusable_list_tile.dart'; +import '../../widgets/tv_color_picker.dart'; import '../../widgets/tv_number_spinner.dart'; /// Model for option selection dialogs. @@ -230,6 +232,106 @@ void _showNumericInputDialogStandard({ }); } +/// Convert `#RRGGBB` (or `#AARRGGBB`) hex to [Color]. Defaults to black on parse error. +Color hexToColor(String hex) { + final buffer = StringBuffer(); + if (hex.length == 7) buffer.write('ff'); + buffer.write(hex.replaceFirst('#', '')); + return Color(int.tryParse(buffer.toString(), radix: 16) ?? 0xff000000); +} + +/// Convert [Color] to `#RRGGBB` hex (uppercase). Drops alpha. +String colorToHex(Color color) { + String two(num c) => ((c * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0'); + return '#${two(color.r)}${two(color.g)}${two(color.b)}'.toUpperCase(); +} + +/// Shows a color picker dialog. Uses [TvColorPicker] in keyboard/D-pad mode, +/// otherwise the standard FlexColorPicker. Calls [onSave] with `#RRGGBB`. +void showColorInputDialog({ + required BuildContext context, + required String title, + required String currentHex, + required Future Function(String hex) onSave, +}) { + if (InputModeTracker.isKeyboardMode(context)) { + _showColorInputDialogTV(context: context, title: title, currentHex: currentHex, onSave: onSave); + } else { + _showColorInputDialogStandard(context: context, title: title, currentHex: currentHex, onSave: onSave); + } +} + +Future _showColorInputDialogStandard({ + required BuildContext context, + required String title, + required String currentHex, + required Future Function(String hex) onSave, +}) async { + final initial = hexToColor(currentHex); + final selected = await showColorPickerDialog( + context, + initial, + title: Text(title), + barrierColor: Colors.black54, + width: 40, + height: 40, + spacing: 0, + runSpacing: 0, + borderRadius: 4, + wheelDiameter: 165, + enableOpacity: false, + showColorCode: true, + colorCodeHasColor: true, + pickersEnabled: const { + ColorPickerType.both: false, + ColorPickerType.primary: true, + ColorPickerType.accent: false, + ColorPickerType.wheel: true, + ColorPickerType.custom: false, + }, + actionButtons: const ColorPickerActionButtons(okButton: true, closeButton: true, dialogActionButtons: false), + ); + if (selected != initial) await onSave(colorToHex(selected)); +} + +void _showColorInputDialogTV({ + required BuildContext context, + required String title, + required String currentHex, + required Future Function(String hex) onSave, +}) { + Color picked = hexToColor(currentHex); + final saveFocusNode = FocusNode(); + showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(title), + content: TvColorPicker( + initialColor: picked, + onColorChanged: (c) => setDialogState(() => picked = c), + onConfirm: () => saveFocusNode.requestFocus(), + ), + actions: [ + DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), + DialogActionButton( + focusNode: saveFocusNode, + onPressed: () async { + await onSave(colorToHex(picked)); + if (dialogContext.mounted) Navigator.pop(dialogContext); + }, + label: t.common.save, + ), + ], + ); + }, + ); + }, + ).then((_) => saveFocusNode.dispose()); +} + /// Shows a text input dialog with regex validation and reset-to-default support. void showRegexInputDialog({ required BuildContext context, diff --git a/lib/screens/settings/subtitle_styling_screen.dart b/lib/screens/settings/subtitle_styling_screen.dart index 028f6120..e7d83a5f 100644 --- a/lib/screens/settings/subtitle_styling_screen.dart +++ b/lib/screens/settings/subtitle_styling_screen.dart @@ -1,157 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:flex_color_picker/flex_color_picker.dart'; -import '../../focus/focusable_button.dart'; -import '../../focus/input_mode_tracker.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_section.dart'; -import '../../widgets/tv_color_picker.dart'; import 'settings_utils.dart'; -class SubtitleStylingScreen extends StatefulWidget { +class SubtitleStylingScreen extends StatelessWidget { const SubtitleStylingScreen({super.key}); - @override - State createState() => _SubtitleStylingScreenState(); -} - -class _SubtitleStylingScreenState extends State { - late SettingsService _settingsService; - bool _isLoading = true; - - int _fontSize = 55; - String _textColor = '#FFFFFF'; - int _borderSize = 3; - String _borderColor = '#000000'; - String _backgroundColor = '#000000'; - int _backgroundOpacity = 0; - int _subtitlePosition = 100; - SubAssOverride _assOverride = SubAssOverride.no; - bool _bold = false; - bool _italic = false; - - @override - void initState() { - super.initState(); - _loadSettings(); - } - - Future _loadSettings() async { - _settingsService = await SettingsService.getInstance(); - - if (!mounted) return; - setState(() { - _fontSize = _settingsService.read(SettingsService.subtitleFontSize); - _textColor = _settingsService.read(SettingsService.subtitleTextColor); - _borderSize = _settingsService.read(SettingsService.subtitleBorderSize); - _borderColor = _settingsService.read(SettingsService.subtitleBorderColor); - _backgroundColor = _settingsService.read(SettingsService.subtitleBackgroundColor); - _backgroundOpacity = _settingsService.read(SettingsService.subtitleBackgroundOpacity); - _subtitlePosition = _settingsService.read(SettingsService.subtitlePosition); - _assOverride = _settingsService.read(SettingsService.subAssOverride); - _bold = _settingsService.read(SettingsService.subtitleBold); - _italic = _settingsService.read(SettingsService.subtitleItalic); - _isLoading = false; - }); - } - - Color _hexToColor(String hexString) { - final buffer = StringBuffer(); - if (hexString.length == 7) buffer.write('ff'); - buffer.write(hexString.replaceFirst('#', '')); - return Color(int.parse(buffer.toString(), radix: 16)); - } - - String _colorToHex(Color color) { - return '#${((color.r * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.g * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.b * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}' - .toUpperCase(); - } - - Future _showColorPicker(String title, String currentColor, Function(String) onColorSelected) async { - final Color initialColor = _hexToColor(currentColor); - - final Color selectedColor = await showColorPickerDialog( - context, - initialColor, - title: Text(title), - barrierColor: Colors.black54, - width: 40, - height: 40, - spacing: 0, - runSpacing: 0, - borderRadius: 4, - wheelDiameter: 165, - enableOpacity: false, - showColorCode: true, - colorCodeHasColor: true, - pickersEnabled: const { - ColorPickerType.both: false, - ColorPickerType.primary: true, - ColorPickerType.accent: false, - ColorPickerType.wheel: true, - ColorPickerType.custom: false, - }, - actionButtons: const ColorPickerActionButtons(okButton: true, closeButton: true, dialogActionButtons: false), - ); - - final hexColor = _colorToHex(selectedColor); - onColorSelected(hexColor); - } - - void _showTvColorPicker(String title, String currentColor, Function(String) onColorSelected) { - Color pickerColor = _hexToColor(currentColor); - final saveFocusNode = FocusNode(); - - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(title), - content: TvColorPicker( - initialColor: pickerColor, - onColorChanged: (color) => setDialogState(() => pickerColor = color), - onConfirm: () => saveFocusNode.requestFocus(), - ), - actions: [ - FocusableButton( - onPressed: () => Navigator.pop(dialogContext), - child: TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - ), - FocusableButton( - focusNode: saveFocusNode, - onPressed: () { - onColorSelected(_colorToHex(pickerColor)); - Navigator.pop(dialogContext); - }, - child: TextButton( - onPressed: () { - onColorSelected(_colorToHex(pickerColor)); - Navigator.pop(dialogContext); - }, - child: Text(t.common.save), - ), - ), - ], - ); - }, - ); - }, - ).then((_) => saveFocusNode.dispose()); - } - - void _openColorPicker(String title, String currentColor, Function(String) onColorSelected) { - if (InputModeTracker.isKeyboardMode(context)) { - _showTvColorPicker(title, currentColor, onColorSelected); - } else { - _showColorPicker(title, currentColor, onColorSelected); - } - } - String _assOverrideLabel(SubAssOverride value) { return switch (value) { SubAssOverride.no => 'No', @@ -170,190 +29,89 @@ class _SubtitleStylingScreenState extends State { @override Widget build(BuildContext context) { - if (_isLoading) { - return FocusedScrollScaffold( - title: Text(t.screens.subtitleStyling), - slivers: [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], - ); - } - return FocusedScrollScaffold( title: Text(t.screens.subtitleStyling), slivers: [ SliverList( delegate: SliverChildListDelegate([ SettingsSectionHeader(t.subtitlingStyling.text), - ListTile( - leading: const AppIcon(Symbols.subtitles_rounded, fill: 1), - title: Text(t.subtitlingStyling.assOverride), - subtitle: Text(_assOverrideLabel(_assOverride)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () async { - final value = await showSelectionDialog( - context: context, - title: t.subtitlingStyling.assOverride, - options: SubAssOverride.values - .map((v) => DialogOption(value: v, title: _assOverrideLabel(v))) - .toList(), - currentValue: _assOverride, - ); - if (value != null) { - setState(() => _assOverride = value); - await _settingsService.write(SettingsService.subAssOverride, value); - } - }, + 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, ), - ListTile( - leading: const AppIcon(Symbols.format_size_rounded, fill: 1), - title: Text(t.subtitlingStyling.fontSize), - subtitle: Text('$_fontSize'), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: t.subtitlingStyling.fontSize, - labelText: t.subtitlingStyling.fontSize, - suffixText: '', - min: 10, - max: 80, - currentValue: _fontSize, - onSave: (value) async { - setState(() => _fontSize = value); - await _settingsService.write(SettingsService.subtitleFontSize, value); - }, - ), + 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, ), - ListTile( - leading: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: _hexToColor(_textColor), - border: const Border.fromBorderSide(BorderSide(color: Colors.grey)), - borderRadius: const BorderRadius.all(Radius.circular(4)), - ), - ), - title: Text(t.subtitlingStyling.textColor), - subtitle: Text(_textColor), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _openColorPicker(t.subtitlingStyling.textColor, _textColor, (color) { - setState(() => _textColor = color); - _settingsService.write(SettingsService.subtitleTextColor, color); - }), + SettingColorTile( + pref: SettingsService.subtitleTextColor, + icon: Symbols.format_color_text_rounded, + title: t.subtitlingStyling.textColor, ), - ListTile( - leading: const AppIcon(Symbols.vertical_align_bottom_rounded, fill: 1), - title: Text(t.subtitlingStyling.position), - subtitle: Text(_formatPosition(_subtitlePosition)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: t.subtitlingStyling.position, - labelText: t.subtitlingStyling.position, - suffixText: '%', - min: 0, - max: 100, - currentValue: _subtitlePosition, - onSave: (value) async { - setState(() => _subtitlePosition = value); - await _settingsService.write(SettingsService.subtitlePosition, value); - }, - ), + 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, ), - SwitchListTile( - secondary: const AppIcon(Symbols.format_bold_rounded, fill: 1), - title: Text(t.subtitlingStyling.bold), - value: _bold, - onChanged: (value) async { - setState(() => _bold = value); - await _settingsService.write(SettingsService.subtitleBold, value); - }, + SettingSwitchTile( + pref: SettingsService.subtitleBold, + icon: Symbols.format_bold_rounded, + title: t.subtitlingStyling.bold, ), - SwitchListTile( - secondary: const AppIcon(Symbols.format_italic_rounded, fill: 1), - title: Text(t.subtitlingStyling.italic), - value: _italic, - onChanged: (value) async { - setState(() => _italic = value); - await _settingsService.write(SettingsService.subtitleItalic, value); - }, + SettingSwitchTile( + pref: SettingsService.subtitleItalic, + icon: Symbols.format_italic_rounded, + title: t.subtitlingStyling.italic, ), SettingsSectionHeader(t.subtitlingStyling.border), - ListTile( - leading: const AppIcon(Symbols.border_style_rounded, fill: 1), - title: Text(t.subtitlingStyling.borderSize), - subtitle: Text('$_borderSize'), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: t.subtitlingStyling.borderSize, - labelText: t.subtitlingStyling.borderSize, - suffixText: '', - min: 0, - max: 5, - currentValue: _borderSize, - onSave: (value) async { - setState(() => _borderSize = value); - await _settingsService.write(SettingsService.subtitleBorderSize, value); - }, - ), + 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, ), - ListTile( - leading: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: _hexToColor(_borderColor), - border: const Border.fromBorderSide(BorderSide(color: Colors.grey)), - borderRadius: const BorderRadius.all(Radius.circular(4)), - ), - ), - title: Text(t.subtitlingStyling.borderColor), - subtitle: Text(_borderColor), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _openColorPicker(t.subtitlingStyling.borderColor, _borderColor, (color) { - setState(() => _borderColor = color); - _settingsService.write(SettingsService.subtitleBorderColor, color); - }), + SettingColorTile( + pref: SettingsService.subtitleBorderColor, + icon: Symbols.border_color_rounded, + title: t.subtitlingStyling.borderColor, ), SettingsSectionHeader(t.subtitlingStyling.background), - ListTile( - leading: const AppIcon(Symbols.opacity_rounded, fill: 1), - title: Text(t.subtitlingStyling.backgroundOpacity), - subtitle: Text('$_backgroundOpacity%'), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: t.subtitlingStyling.backgroundOpacity, - labelText: t.subtitlingStyling.backgroundOpacity, - suffixText: '%', - min: 0, - max: 100, - currentValue: _backgroundOpacity, - onSave: (value) async { - setState(() => _backgroundOpacity = value); - await _settingsService.write(SettingsService.subtitleBackgroundOpacity, value); - }, - ), + SettingNumberTile( + pref: SettingsService.subtitleBackgroundOpacity, + icon: Symbols.opacity_rounded, + title: t.subtitlingStyling.backgroundOpacity, + subtitleBuilder: (v) => '$v%', + labelText: t.subtitlingStyling.backgroundOpacity, + suffixText: '%', + min: 0, + max: 100, ), - ListTile( - leading: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: _hexToColor(_backgroundColor), - border: const Border.fromBorderSide(BorderSide(color: Colors.grey)), - borderRadius: const BorderRadius.all(Radius.circular(4)), - ), - ), - title: Text(t.subtitlingStyling.backgroundColor), - subtitle: Text(_backgroundColor), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _openColorPicker(t.subtitlingStyling.backgroundColor, _backgroundColor, (color) { - setState(() => _backgroundColor = color); - _settingsService.write(SettingsService.subtitleBackgroundColor, color); - }), + 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_library_filter_screen.dart b/lib/screens/settings/tracker_library_filter_screen.dart index 007ac634..a90318f1 100644 --- a/lib/screens/settings/tracker_library_filter_screen.dart +++ b/lib/screens/settings/tracker_library_filter_screen.dart @@ -10,12 +10,14 @@ 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_section.dart'; /// Per-provider library whitelist/blacklist screen. Toggling a switch /// adds/removes the library from the filter set; "selected" means "in the /// filter list" in both modes. -class TrackerLibraryFilterScreen extends StatefulWidget { +class TrackerLibraryFilterScreen extends StatelessWidget { final TrackerService service; const TrackerLibraryFilterScreen({super.key, required this.service}); @@ -36,125 +38,92 @@ class TrackerLibraryFilterScreen extends StatefulWidget { : t.trackers.libraryFilter.subtitleAllowed(count: count); } - @override - State createState() => _TrackerLibraryFilterScreenState(); -} - -class _TrackerLibraryFilterScreenState extends State { - late SettingsService _settings; - TrackerLibraryFilterMode _mode = TrackerLibraryFilterMode.blacklist; - final Set _selectedIds = {}; - bool _loaded = false; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - final s = await SettingsService.getInstance(); - if (!mounted) return; - setState(() { - _settings = s; - _mode = s.read(SettingsService.trackerFilterModePref(widget.service)); - _selectedIds - ..clear() - ..addAll(s.read(SettingsService.trackerFilterIdsPref(widget.service)).toSet()); - _loaded = true; - }); - } - - Future _setMode(TrackerLibraryFilterMode mode) async { - if (mode == _mode) return; - setState(() => _mode = mode); - await _settings.write(SettingsService.trackerFilterModePref(widget.service), mode); - } - - Future _toggleLibrary(String globalKey, bool value) async { - setState(() { - if (value) { - _selectedIds.add(globalKey); - } else { - _selectedIds.remove(globalKey); - } - }); - await _settings.write(SettingsService.trackerFilterIdsPref(widget.service), _selectedIds.toList()); - } - @override Widget build(BuildContext context) { final title = Text(t.trackers.libraryFilter.title); - if (!_loaded) { - return FocusedScrollScaffold( - title: title, - slivers: const [SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], - ); - } + final modePref = SettingsService.trackerFilterModePref(service); + final idsPref = SettingsService.trackerFilterIdsPref(service); - final theme = Theme.of(context); + return SettingsBuilder( + prefs: [modePref, idsPref], + builder: (context) { + final settings = SettingsService.instanceOrNull!; + final mode = settings.read(modePref); + final selectedIds = settings.read(idsPref).toSet(); + final theme = Theme.of(context); - return Consumer( - builder: (context, provider, _) { - final libraries = provider.libraries; - final grouped = _groupByServer(libraries); - final showServerHeaders = grouped.length > 1; + return Consumer( + builder: (context, provider, _) { + final libraries = provider.libraries; + final grouped = _groupByServer(libraries); + final showServerHeaders = grouped.length > 1; - final children = [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Text( - _mode == TrackerLibraryFilterMode.blacklist - ? t.trackers.libraryFilter.modeHintBlacklist - : t.trackers.libraryFilter.modeHintWhitelist, - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - ), - SegmentedSetting( - icon: Symbols.filter_list_rounded, - title: t.trackers.libraryFilter.mode, - segments: [ - ButtonSegment( - value: TrackerLibraryFilterMode.blacklist, - label: Text(t.trackers.libraryFilter.modeBlacklist), - ), - ButtonSegment( - value: TrackerLibraryFilterMode.whitelist, - label: Text(t.trackers.libraryFilter.modeWhitelist), - ), - ], - selected: _mode, - onChanged: _setMode, - ), - SettingsSectionHeader(t.trackers.libraryFilter.libraries), - ]; - - if (libraries.isEmpty) { - children.add(ListTile(title: Text(t.trackers.libraryFilter.noLibraries))); - } else { - for (final entry in grouped.entries) { - if (showServerHeaders) { - children.add(SettingsSectionHeader(entry.value.first.serverName ?? entry.key)); - } - for (final lib in entry.value) { - children.add( - FocusableSwitchListTile( - key: ValueKey('tracker-library-filter-${lib.globalKey}'), - secondary: const AppIcon(Symbols.folder_rounded, fill: 1), - title: Text(lib.title), - value: _selectedIds.contains(lib.globalKey), - onChanged: (v) => _toggleLibrary(lib.globalKey, v), + final children = [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Text( + mode == TrackerLibraryFilterMode.blacklist + ? t.trackers.libraryFilter.modeHintBlacklist + : t.trackers.libraryFilter.modeHintWhitelist, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), - ); + ), + SettingSegmentedTile( + pref: modePref, + icon: Symbols.filter_list_rounded, + title: t.trackers.libraryFilter.mode, + segments: [ + ButtonSegment( + value: TrackerLibraryFilterMode.blacklist, + label: Text(t.trackers.libraryFilter.modeBlacklist), + ), + ButtonSegment( + value: TrackerLibraryFilterMode.whitelist, + label: Text(t.trackers.libraryFilter.modeWhitelist), + ), + ], + decode: (v) => v, + encode: (v) => v, + ), + SettingsSectionHeader(t.trackers.libraryFilter.libraries), + ]; + + if (libraries.isEmpty) { + children.add(ListTile(title: Text(t.trackers.libraryFilter.noLibraries))); + } else { + for (final entry in grouped.entries) { + if (showServerHeaders) { + children.add(SettingsSectionHeader(entry.value.first.serverName ?? entry.key)); + } + for (final lib in entry.value) { + children.add( + FocusableSwitchListTile( + key: ValueKey('tracker-library-filter-${lib.globalKey}'), + secondary: const AppIcon(Symbols.folder_rounded, fill: 1), + title: Text(lib.title), + value: selectedIds.contains(lib.globalKey), + onChanged: (v) async { + final next = Set.of(selectedIds); + if (v) { + next.add(lib.globalKey); + } else { + next.remove(lib.globalKey); + } + await settings.write(idsPref, next.toList()); + }, + ), + ); + } + } } - } - } - children.add(const SizedBox(height: 24)); + children.add(const SizedBox(height: 24)); - return FocusedScrollScaffold( - title: title, - slivers: [SliverList(delegate: SliverChildListDelegate(children))], + return FocusedScrollScaffold( + title: title, + slivers: [SliverList(delegate: SliverChildListDelegate(children))], + ); + }, ); }, ); diff --git a/lib/screens/settings/tracker_settings_loader.dart b/lib/screens/settings/tracker_settings_loader.dart deleted file mode 100644 index 45f54fbc..00000000 --- a/lib/screens/settings/tracker_settings_loader.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../services/settings_service.dart'; - -mixin TrackerSettingsLoadMixin on State { - SettingsService? trackerSettings; - bool trackerSettingsLoaded = false; - - @override - void initState() { - super.initState(); - loadTrackerSettings(); - } - - Future loadTrackerSettings() async { - final settings = await SettingsService.getInstance(); - if (!mounted) return; - setState(() { - trackerSettings = settings; - readTrackerSettings(settings); - trackerSettingsLoaded = true; - }); - } - - void readTrackerSettings(SettingsService settings); -} diff --git a/lib/screens/settings/tracker_settings_screen.dart b/lib/screens/settings/tracker_settings_screen.dart index 238c1632..f33ab9eb 100644 --- a/lib/screens/settings/tracker_settings_screen.dart +++ b/lib/screens/settings/tracker_settings_screen.dart @@ -16,10 +16,11 @@ 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 'tracker_connect_launcher.dart'; import 'tracker_library_filter_screen.dart'; -import 'tracker_settings_loader.dart'; Future startMalConnection(BuildContext context) { final account = context.read(); @@ -70,8 +71,8 @@ class TrackerConfig { final String displayName; final bool Function(TrackersProvider) isConnected; final String? Function(TrackersProvider) username; - final bool Function(SettingsService) readScrobbleEnabled; - final Future Function(SettingsService, bool) setScrobbleEnabled; + final Pref scrobblePref; + final Future Function(bool) onScrobbleChanged; final Future Function(TrackersProvider) disconnect; const TrackerConfig({ @@ -79,8 +80,8 @@ class TrackerConfig { required this.displayName, required this.isConnected, required this.username, - required this.readScrobbleEnabled, - required this.setScrobbleEnabled, + required this.scrobblePref, + required this.onScrobbleChanged, required this.disconnect, }); @@ -89,11 +90,8 @@ class TrackerConfig { displayName: t.trackers.services.mal, isConnected: (a) => a.isMalConnected, username: (a) => a.malUsername, - readScrobbleEnabled: (s) => s.read(SettingsService.enableMalScrobble), - setScrobbleEnabled: (s, v) async { - await s.write(SettingsService.enableMalScrobble, v); - await MalTracker.instance.setEnabled(v); - }, + scrobblePref: SettingsService.enableMalScrobble, + onScrobbleChanged: MalTracker.instance.setEnabled, disconnect: (a) => a.disconnectMal(), ); @@ -102,11 +100,8 @@ class TrackerConfig { displayName: t.trackers.services.anilist, isConnected: (a) => a.isAnilistConnected, username: (a) => a.anilistUsername, - readScrobbleEnabled: (s) => s.read(SettingsService.enableAnilistScrobble), - setScrobbleEnabled: (s, v) async { - await s.write(SettingsService.enableAnilistScrobble, v); - await AnilistTracker.instance.setEnabled(v); - }, + scrobblePref: SettingsService.enableAnilistScrobble, + onScrobbleChanged: AnilistTracker.instance.setEnabled, disconnect: (a) => a.disconnectAnilist(), ); @@ -115,11 +110,8 @@ class TrackerConfig { displayName: t.trackers.services.simkl, isConnected: (a) => a.isSimklConnected, username: (a) => a.simklUsername, - readScrobbleEnabled: (s) => s.read(SettingsService.enableSimklScrobble), - setScrobbleEnabled: (s, v) async { - await s.write(SettingsService.enableSimklScrobble, v); - await SimklTracker.instance.setEnabled(v); - }, + scrobblePref: SettingsService.enableSimklScrobble, + onScrobbleChanged: SimklTracker.instance.setEnabled, disconnect: (a) => a.disconnectSimkl(), ); } @@ -127,50 +119,31 @@ class TrackerConfig { /// Shared settings screen for MAL, AniList, and Simkl. Only reachable while /// connected — if the session drops (refresh failure, back-nav race) we pop /// back to the hub. -class TrackerSettingsScreen extends StatefulWidget { +class TrackerSettingsScreen extends StatelessWidget { final TrackerConfig config; const TrackerSettingsScreen({super.key, required this.config}); - @override - State createState() => _TrackerSettingsScreenState(); -} - -class _TrackerSettingsScreenState extends State - with TrackerSettingsLoadMixin { - bool _scrobbleEnabled = true; - - @override - void readTrackerSettings(SettingsService settings) { - _scrobbleEnabled = widget.config.readScrobbleEnabled(settings); - } - - Future _disconnect(TrackersProvider account) async { + Future _disconnect(BuildContext context, TrackersProvider account) async { final confirmed = await showConfirmDialog( context, - title: t.trackers.disconnectConfirm(service: widget.config.displayName), - message: t.trackers.disconnectConfirmBody(service: widget.config.displayName), + title: t.trackers.disconnectConfirm(service: config.displayName), + message: t.trackers.disconnectConfirmBody(service: config.displayName), confirmText: t.common.disconnect, isDestructive: true, ); if (!confirmed) return; - await widget.config.disconnect(account); + await config.disconnect(account); } @override Widget build(BuildContext context) { - final title = Text(widget.config.displayName); - if (!trackerSettingsLoaded) { - return FocusedScrollScaffold( - title: title, - slivers: const [SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], - ); - } + final title = Text(config.displayName); return Consumer( builder: (context, account, _) { - if (!widget.config.isConnected(account)) { + if (!config.isConnected(account)) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) Navigator.of(context).pop(); + if (context.mounted) Navigator.of(context).pop(); }); return FocusedScrollScaffold( title: title, @@ -178,7 +151,7 @@ class _TrackerSettingsScreenState extends State ); } - final username = widget.config.username(account); + final username = config.username(account); return FocusedScrollScaffold( title: title, slivers: [ @@ -186,40 +159,39 @@ class _TrackerSettingsScreenState extends State delegate: SliverChildListDelegate([ ListTile( leading: const AppIcon(Symbols.account_circle_rounded, fill: 1), - title: Text( - username != null ? t.trackers.connectedAs(username: username) : widget.config.displayName, - ), + title: Text(username != null ? t.trackers.connectedAs(username: username) : config.displayName), ), SettingsSectionHeader(t.settings.behavior), - SwitchListTile( - secondary: const AppIcon(Symbols.auto_timer, fill: 1), - title: Text(t.trackers.scrobble), - subtitle: Text(t.trackers.scrobbleDescription), - value: _scrobbleEnabled, - onChanged: (value) async { - setState(() => _scrobbleEnabled = value); - await widget.config.setScrobbleEnabled(trackerSettings!, value); - }, + SettingSwitchTile( + pref: config.scrobblePref, + icon: Symbols.auto_timer, + title: t.trackers.scrobble, + subtitle: t.trackers.scrobbleDescription, + onAfterWrite: config.onScrobbleChanged, ), - ListTile( - leading: const AppIcon(Symbols.filter_list_rounded, fill: 1), - title: Text(t.trackers.libraryFilter.title), - subtitle: Text(TrackerLibraryFilterScreen.subtitleFor(trackerSettings!, widget.config.service)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TrackerLibraryFilterScreen(service: widget.config.service), + 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)), ), ); - if (mounted) setState(() {}); }, ), 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(account), + onTap: () => _disconnect(context, account), ), const SizedBox(height: 24), ]), diff --git a/lib/screens/settings/trakt_settings_screen.dart b/lib/screens/settings/trakt_settings_screen.dart index 6ca838cc..a180446c 100644 --- a/lib/screens/settings/trakt_settings_screen.dart +++ b/lib/screens/settings/trakt_settings_screen.dart @@ -13,10 +13,11 @@ 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 'tracker_connect_launcher.dart'; import 'tracker_library_filter_screen.dart'; -import 'tracker_settings_loader.dart'; Future startTraktConnection(BuildContext context) { final account = context.read(); @@ -32,24 +33,10 @@ Future startTraktConnection(BuildContext context) { ); } -class TraktSettingsScreen extends StatefulWidget { +class TraktSettingsScreen extends StatelessWidget { const TraktSettingsScreen({super.key}); - @override - State createState() => _TraktSettingsScreenState(); -} - -class _TraktSettingsScreenState extends State with TrackerSettingsLoadMixin { - bool _scrobbleEnabled = true; - bool _watchedSyncEnabled = true; - - @override - void readTrackerSettings(SettingsService settings) { - _scrobbleEnabled = settings.read(SettingsService.enableTraktScrobble); - _watchedSyncEnabled = settings.read(SettingsService.enableTraktWatchedSync); - } - - Future _disconnect(TraktAccountProvider account) async { + Future _disconnect(BuildContext context, TraktAccountProvider account) async { final confirmed = await showConfirmDialog( context, title: t.trakt.disconnectConfirm, @@ -65,13 +52,6 @@ class _TraktSettingsScreenState extends State with TrackerS @override Widget build(BuildContext context) { - if (!trackerSettingsLoaded) { - return FocusedScrollScaffold( - title: Text(t.trakt.title), - slivers: const [SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], - ); - } - return Consumer( builder: (context, account, _) { // Safety net: if we end up here while not connected (e.g. refresh failed @@ -79,7 +59,7 @@ class _TraktSettingsScreenState extends State with TrackerS // tile is the only supported entry point for the unauthed flow. if (!account.isConnected) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) Navigator.of(context).pop(); + if (context.mounted) Navigator.of(context).pop(); }); return FocusedScrollScaffold( title: Text(t.trakt.title), @@ -99,47 +79,45 @@ class _TraktSettingsScreenState extends State with TrackerS subtitle: Text(t.trakt.connected), ), SettingsSectionHeader(t.settings.behavior), - SwitchListTile( - secondary: const AppIcon(Symbols.auto_timer, fill: 1), - title: Text(t.trakt.scrobble), - subtitle: Text(t.trakt.scrobbleDescription), - value: _scrobbleEnabled, - onChanged: (value) async { - setState(() => _scrobbleEnabled = value); - await trackerSettings!.write(SettingsService.enableTraktScrobble, value); - await TraktScrobbleService.instance.setEnabled(value); - }, + SettingSwitchTile( + pref: SettingsService.enableTraktScrobble, + icon: Symbols.auto_timer, + title: t.trakt.scrobble, + subtitle: t.trakt.scrobbleDescription, + onAfterWrite: TraktScrobbleService.instance.setEnabled, ), - SwitchListTile( - secondary: const AppIcon(Symbols.check_circle_rounded, fill: 1), - title: Text(t.trakt.watchedSync), - subtitle: Text(t.trakt.watchedSyncDescription), - value: _watchedSyncEnabled, - onChanged: (value) async { - setState(() => _watchedSyncEnabled = value); - await trackerSettings!.write(SettingsService.enableTraktWatchedSync, value); - await TraktSyncService.instance.setEnabled(value); - }, + SettingSwitchTile( + pref: SettingsService.enableTraktWatchedSync, + icon: Symbols.check_circle_rounded, + title: t.trakt.watchedSync, + subtitle: t.trakt.watchedSyncDescription, + onAfterWrite: TraktSyncService.instance.setEnabled, ), - ListTile( - leading: const AppIcon(Symbols.filter_list_rounded, fill: 1), - title: Text(t.trackers.libraryFilter.title), - subtitle: Text(TrackerLibraryFilterScreen.subtitleFor(trackerSettings!, TrackerService.trakt)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const TrackerLibraryFilterScreen(service: TrackerService.trakt), + 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), + ), ), ); - if (mounted) setState(() {}); }, ), 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(account), + onTap: () => _disconnect(context, account), ), const SizedBox(height: 24), ]), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 5c5c3536..a01a3f6d 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -46,7 +46,6 @@ import '../services/playback_progress_tracker.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/display_mode_service.dart'; import '../services/settings_service.dart'; -import '../providers/settings_provider.dart'; import '../services/sleep_timer_service.dart'; import '../services/track_manager.dart'; import '../services/ambient_lighting_service.dart'; @@ -152,7 +151,7 @@ class VideoPlayerScreen extends StatefulWidget { final bool isOffline; /// Quality preset override for this playback. When `null`, the screen uses - /// the user's default from [SettingsProvider]. + /// the user's [SettingsService.defaultQualityPreset]. final TranscodeQualityPreset? selectedQualityPreset; /// Audio stream ID to pass to the transcoder when [selectedQualityPreset] @@ -580,12 +579,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // `availableVersions.length`, not transcoding capability. _serverSupportsTranscoding = genericClient.capabilities.videoTranscoding; if (widget.selectedQualityPreset == null) { - try { - final settingsProvider = context.read(); - _selectedQualityPreset = settingsProvider.defaultQualityPreset; - } catch (_) { - _selectedQualityPreset = TranscodeQualityPreset.original; - } + _selectedQualityPreset = settingsService.read(SettingsService.defaultQualityPreset); } else { _selectedQualityPreset = widget.selectedQualityPreset!; } diff --git a/lib/services/base_shared_preferences_service.dart b/lib/services/base_shared_preferences_service.dart index ff7786f4..64e07632 100644 --- a/lib/services/base_shared_preferences_service.dart +++ b/lib/services/base_shared_preferences_service.dart @@ -112,9 +112,21 @@ abstract class BaseSharedPreferencesService { /// when the value changes. Notifiers live for the app lifetime — do not /// dispose them. final Map> _listenables = {}; + final Map> _listenablePrefs = {}; - ValueNotifier listenable(Pref pref) { - return (_listenables[pref.key] ??= ValueNotifier(read(pref))) as ValueNotifier; + ValueNotifier listenable(Pref pref) => pref.bindListenable(this); + + /// Type-erased [Listenable] accessor for combining multiple prefs into a + /// `Listenable.merge`. Dispatches through [Pref.bindListenable] so the + /// underlying notifier is created with the pref's concrete type. + Listenable listenableOf(Pref pref) => pref.bindListenable(this); + + /// Push current stored values into every active listenable. Used after bulk + /// operations that bypass [write] (reset/import/direct SharedPreferences writes). + void refreshActiveListenables() { + for (final pref in _listenablePrefs.values.toList(growable: false)) { + pref.refreshListenable(this); + } } /// Hook for subclass-specific initialization after SharedPreferences is ready. @@ -138,6 +150,27 @@ abstract class Pref { /// Implementation hook — call [BaseSharedPreferencesService.write] instead. Future writeTo(BaseSharedPreferencesService svc, T value); + + /// Get-or-create the [ValueNotifier] for this pref. Virtual-dispatched via + /// the runtime [Pref] subclass so the notifier carries the concrete `T`, + /// even when called through a `Pref` reference (used by + /// [BaseSharedPreferencesService.listenableOf]). + ValueNotifier bindListenable(BaseSharedPreferencesService svc) { + final existing = svc._listenables[key]; + svc._listenablePrefs[key] = this; + if (existing != null) return existing as ValueNotifier; + final notifier = ValueNotifier(readFrom(svc)); + svc._listenables[key] = notifier; + return notifier; + } + + /// If a listenable exists for this key, push the current stored value into + /// it. Used after bulk operations (reset, import) that bypass [writeTo]. + /// No-op when no listener has been registered. + void refreshListenable(BaseSharedPreferencesService svc) { + final n = svc._listenables[key]; + if (n != null) (n as ValueNotifier).value = readFrom(svc); + } } class BoolPref extends Pref { diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 4bee3f79..657c3383 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -1,6 +1,7 @@ import 'dart:async' show unawaited; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../models/hotkey_model.dart'; @@ -10,9 +11,10 @@ import 'settings_service.dart'; import '../utils/platform_detector.dart'; import '../utils/player_utils.dart'; -class KeyboardShortcutsService { +class KeyboardShortcutsService extends ChangeNotifier { static KeyboardShortcutsService? _instance; late SettingsService _settingsService; + final List _settingsDisposers = []; Map _shortcuts = {}; // Legacy string shortcuts for backward compatibility Map _hotkeys = {}; // New HotKey objects int _seekTimeSmall = 10; // Default, loaded from settings @@ -36,13 +38,57 @@ class KeyboardShortcutsService { Future _init() async { _settingsService = await SettingsService.getInstance(); - // Ensure settings service is fully initialized before loading data - await Future.delayed(Duration.zero); // Allow event loop to complete - _shortcuts = _settingsService.read(SettingsService.keyboardShortcuts); // Keep for legacy compatibility - _hotkeys = _settingsService.read(SettingsService.keyboardHotkeys); // Primary method - _seekTimeSmall = _settingsService.read(SettingsService.seekTimeSmall); - _seekTimeLarge = _settingsService.read(SettingsService.seekTimeLarge); - _maxVolume = _settingsService.read(SettingsService.maxVolume); + _bindSettings(); + _syncFromSettings(notify: false); + } + + void _bindSettings() { + if (_settingsDisposers.isNotEmpty) return; + void bind(Pref pref) { + final notifier = _settingsService.listenable(pref); + notifier.addListener(_onSettingsChanged); + _settingsDisposers.add(() => notifier.removeListener(_onSettingsChanged)); + } + + bind(SettingsService.keyboardShortcuts); + bind(SettingsService.keyboardHotkeys); + bind(SettingsService.seekTimeSmall); + bind(SettingsService.seekTimeLarge); + bind(SettingsService.maxVolume); + } + + void _onSettingsChanged() => _syncFromSettings(); + + void _syncFromSettings({bool notify = true}) { + final shortcuts = _settingsService.read(SettingsService.keyboardShortcuts); + final hotkeys = _settingsService.read(SettingsService.keyboardHotkeys); + final seekTimeSmall = _settingsService.read(SettingsService.seekTimeSmall); + final seekTimeLarge = _settingsService.read(SettingsService.seekTimeLarge); + final maxVolume = _settingsService.read(SettingsService.maxVolume); + + final changed = + !mapEquals(_shortcuts, shortcuts) || + !_hotkeyMapsEqual(_hotkeys, hotkeys) || + _seekTimeSmall != seekTimeSmall || + _seekTimeLarge != seekTimeLarge || + _maxVolume != maxVolume; + + _shortcuts = Map.from(shortcuts); + _hotkeys = Map.from(hotkeys); + _seekTimeSmall = seekTimeSmall; + _seekTimeLarge = seekTimeLarge; + _maxVolume = maxVolume; + + if (notify && changed) notifyListeners(); + } + + bool _hotkeyMapsEqual(Map a, Map b) { + if (a.length != b.length) return false; + for (final entry in a.entries) { + final other = b[entry.key]; + if (other == null || !_hotkeyEquals(entry.value, other)) return false; + } + return true; } Map get shortcuts => Map.from(_shortcuts); @@ -58,39 +104,32 @@ class KeyboardShortcutsService { } Future setShortcut(String action, String key) async { - _shortcuts[action] = key; - await _settingsService.write(SettingsService.keyboardShortcuts, _shortcuts); + await _settingsService.write(SettingsService.keyboardShortcuts, {..._shortcuts, action: key}); } Future setHotkey(String action, HotKey hotkey) async { - // Update local cache first - _hotkeys[action] = hotkey; - - // Save to persistent storage - await _settingsService.write(SettingsService.keyboardHotkeys, { - ..._settingsService.read(SettingsService.keyboardHotkeys), - action: hotkey, - }); - - // Verify local cache is still correct - if (_hotkeys[action] != hotkey) { - _hotkeys[action] = hotkey; // Restore correct value - } + await _settingsService.write(SettingsService.keyboardHotkeys, {..._hotkeys, action: hotkey}); } Future refreshFromStorage() async { - _hotkeys = _settingsService.read(SettingsService.keyboardHotkeys); - _seekTimeSmall = _settingsService.read(SettingsService.seekTimeSmall); - _seekTimeLarge = _settingsService.read(SettingsService.seekTimeLarge); + _syncFromSettings(); } Future resetToDefaults() async { - _shortcuts = SettingsService.defaultKeyboardShortcuts(); - _hotkeys = SettingsService.defaultKeyboardHotkeys(); - await _settingsService.write(SettingsService.keyboardShortcuts, _shortcuts); - await _settingsService.write(SettingsService.keyboardHotkeys, _hotkeys); - // Refresh cache to ensure consistency - await refreshFromStorage(); + final shortcuts = SettingsService.defaultKeyboardShortcuts(); + final hotkeys = SettingsService.defaultKeyboardHotkeys(); + await _settingsService.write(SettingsService.keyboardShortcuts, shortcuts); + await _settingsService.write(SettingsService.keyboardHotkeys, hotkeys); + } + + @override + void dispose() { + for (final dispose in _settingsDisposers) { + dispose(); + } + _settingsDisposers.clear(); + if (identical(_instance, this)) _instance = null; + super.dispose(); } // Format HotKey for display diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 137c97ff..6d5ab70c 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -12,6 +12,7 @@ import '../models/external_player_models.dart'; import 'base_shared_preferences_service.dart'; export 'base_shared_preferences_service.dart' show Pref, BoolPref, IntPref, DoublePref, StringPref, NullableStringPref, StringListPref, EnumPref, JsonPref; +import '../models/transcode_quality_preset.dart'; import '../utils/platform_detector.dart'; import 'trackers/tracker_constants.dart'; @@ -307,7 +308,11 @@ class SettingsService extends BaseSharedPreferencesService { static const enableSimklScrobble = BoolPref('enable_simkl_scrobble', defaultValue: true); static const matchContentFrameRate = BoolPref('match_content_frame_rate'); static const tunneledPlayback = BoolPref('tunneled_playback', defaultValue: true); - static const defaultQualityPreset = StringPref('default_quality_preset', defaultValue: 'original'); + static const defaultQualityPreset = EnumPref( + 'default_quality_preset', + values: TranscodeQualityPreset.values, + defaultValue: TranscodeQualityPreset.original, + ); static const autoPlayNextEpisode = BoolPref('auto_play_next_episode', defaultValue: true); static const useExoPlayer = BoolPref('use_exoplayer', defaultValue: true); static const alwaysKeepSidebarOpen = BoolPref('always_keep_sidebar_open'); @@ -636,89 +641,99 @@ class SettingsService extends BaseSharedPreferencesService { /// reset surface — notably excludes user-customized data (intro/credits regex /// patterns) and opt-in toggles prior versions didn't reset, so behavior /// stays identical for users. - static List _resettableKeys() => [ - enableDebugLogging.key, - bufferSize.key, - enableHardwareDecoding.key, - enableHDR.key, - preferredVideoCodec.key, - preferredAudioCodec.key, - viewMode.key, - showHeroSection.key, - seekTimeSmall.key, - seekTimeLarge.key, - sleepTimerDuration.key, - audioSyncOffset.key, - subtitleSyncOffset.key, - volume.key, - maxVolume.key, - subtitleFontSize.key, - subtitleTextColor.key, - subtitleBorderSize.key, - subtitleBorderColor.key, - subtitleBackgroundColor.key, - subtitleBackgroundOpacity.key, - subtitlePosition.key, - rememberTrackSelections.key, - customDownloadPathType.key, - downloadOnWifiOnly.key, - autoCheckUpdatesOnStartup.key, - showPerformanceOverlay.key, - autoHidePerformanceOverlay.key, - enableDiscordRPC.key, - enableTraktScrobble.key, - enableTraktWatchedSync.key, - enableMalScrobble.key, - enableAnilistScrobble.key, - enableSimklScrobble.key, - matchContentFrameRate.key, - tunneledPlayback.key, - defaultPlaybackSpeed.key, - defaultBoxFitMode.key, - autoPlayNextEpisode.key, - useExoPlayer.key, - alwaysKeepSidebarOpen.key, - showUnwatchedCount.key, - showEpisodeNumberOnCards.key, - showSeasonPostersOnTabs.key, - hideSpoilers.key, - showNavBarLabels.key, - globalShaderPreset.key, - requireProfileSelectionOnOpen.key, - useExternalPlayer.key, - confirmExitOnBack.key, - forceTvMode.key, - ambientLighting.key, - audioPassthrough.key, - audioNormalization.key, - themeMode.key, - keyboardShortcuts.key, - keyboardHotkeys.key, - libraryDensity.key, - _legacyUseSeasonPosterKey, - episodePosterMode.key, - mediaVersionPreferences.key, - appLocale.key, - customDownloadPath.key, - videoPlayerNavigationEnabled.key, - _legacyMpvConfigEntriesKey, - mpvConfigText.key, - mpvPresets.key, - autoPip.key, - customShaderPresets.key, - selectedExternalPlayer.key, - customExternalPlayers.key, - _bufferSizeMigratedKey, - customRelayUrl.key, + static List> _resettablePrefs() => [ + enableDebugLogging, + bufferSize, + enableHardwareDecoding, + enableHDR, + preferredVideoCodec, + preferredAudioCodec, + viewMode, + showHeroSection, + seekTimeSmall, + seekTimeLarge, + sleepTimerDuration, + audioSyncOffset, + subtitleSyncOffset, + volume, + maxVolume, + subtitleFontSize, + subtitleTextColor, + subtitleBorderSize, + subtitleBorderColor, + subtitleBackgroundColor, + subtitleBackgroundOpacity, + subtitlePosition, + rememberTrackSelections, + customDownloadPathType, + downloadOnWifiOnly, + autoCheckUpdatesOnStartup, + showPerformanceOverlay, + autoHidePerformanceOverlay, + enableDiscordRPC, + enableTraktScrobble, + enableTraktWatchedSync, + enableMalScrobble, + enableAnilistScrobble, + enableSimklScrobble, + matchContentFrameRate, + tunneledPlayback, + defaultPlaybackSpeed, + defaultBoxFitMode, + autoPlayNextEpisode, + useExoPlayer, + alwaysKeepSidebarOpen, + showUnwatchedCount, + showEpisodeNumberOnCards, + showSeasonPostersOnTabs, + hideSpoilers, + showNavBarLabels, + globalShaderPreset, + requireProfileSelectionOnOpen, + useExternalPlayer, + confirmExitOnBack, + forceTvMode, + ambientLighting, + audioPassthrough, + audioNormalization, + themeMode, + keyboardShortcuts, + keyboardHotkeys, + libraryDensity, + episodePosterMode, + mediaVersionPreferences, + appLocale, + customDownloadPath, + videoPlayerNavigationEnabled, + mpvConfigText, + mpvPresets, + autoPip, + customShaderPresets, + selectedExternalPlayer, + customExternalPlayers, + customRelayUrl, ]; Future resetAllSettings() async { + final resettable = _resettablePrefs(); await Future.wait([ - ..._resettableKeys().map((k) => prefs.remove(k)), + ...resettable.map((p) => prefs.remove(p.key)), + // Legacy migration sentinels — removed alongside the keys they guarded. + prefs.remove(_legacyUseSeasonPosterKey), + prefs.remove(_legacyMpvConfigEntriesKey), + prefs.remove(_bufferSizeMigratedKey), ...TrackerService.values.expand( (s) => [prefs.remove(trackerFilterModePref(s).key), prefs.remove(trackerFilterIdsPref(s).key)], ), ]); + refreshListenables(); + } + + /// Push current stored values into every active listenable. Use after bulk + /// operations that bypass [write] (e.g. import-from-file rewrites the + /// underlying SharedPreferences directly). + void refreshListenables() { + refreshActiveListenables(); } Future clearCache() async { diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index 31d578ef..df8756b8 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -3,13 +3,15 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:provider/provider.dart'; import '../focus/focus_theme.dart'; import '../focus/focusable_wrapper.dart'; import '../mixins/context_menu_tap_mixin.dart'; import '../models/download_models.dart'; import '../providers/download_provider.dart'; -import '../providers/settings_provider.dart'; +import 'package:provider/provider.dart'; + +import '../services/settings_service.dart'; +import 'settings_builder.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../widgets/collapsible_text.dart'; @@ -88,7 +90,13 @@ class _EpisodeCardState extends State with ContextMenuTapMixin().hideSpoilers; + return SettingValueBuilder( + pref: SettingsService.hideSpoilers, + builder: (context, hideSpoilers, _) => _buildContent(context, hideSpoilers: hideSpoilers), + ); + } + + Widget _buildContent(BuildContext context, {required bool hideSpoilers}) { final shouldBlur = hideSpoilers && widget.episode.shouldHideSpoiler; // Hide progress when offline (not tracked) diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 0538e147..dffecb3d 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -4,13 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:provider/provider.dart'; import '../focus/dpad_navigator.dart'; import '../focus/focus_theme.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; -import '../providers/settings_provider.dart'; -import '../services/settings_service.dart' show EpisodePosterMode; +import '../services/settings_service.dart'; +import 'settings_builder.dart'; import '../utils/grid_size_calculator.dart'; import '../theme/mono_tokens.dart'; import '../focus/locked_hub_controller.dart'; @@ -385,63 +384,105 @@ class HubSectionState extends State { Focus( focusNode: _hubFocusNode, onKeyEvent: _handleKeyEvent, - child: LayoutBuilder( - builder: (context, constraints) { - final settings = context.watch(); - final baseCardWidth = GridSizeCalculator.getCellWidth( - constraints.maxWidth, - context, - settings.libraryDensity, - ); + child: SettingsBuilder( + prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode], + builder: (context) => LayoutBuilder( + builder: (context, constraints) { + final svc = SettingsService.instanceOrNull!; + final baseCardWidth = GridSizeCalculator.getCellWidth( + constraints.maxWidth, + context, + svc.read(SettingsService.libraryDensity), + ); - // Get episode poster mode setting - final episodePosterMode = settings.episodePosterMode; + // Get episode poster mode setting + final episodePosterMode = svc.read(SettingsService.episodePosterMode); - // Determine hub content type for layout decisions - final hasEpisodes = widget.hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode)); - final hasNonEpisodes = widget.hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode)); + // Determine hub content type for layout decisions + final hasEpisodes = widget.hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode)); + final hasNonEpisodes = widget.hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode)); - // Mixed hub = has both episodes AND non-episodes (like Continue Watching) - final isMixedHub = hasEpisodes && hasNonEpisodes; + // Mixed hub = has both episodes AND non-episodes (like Continue Watching) + final isMixedHub = hasEpisodes && hasNonEpisodes; - // Episode-only = all items are episodes with thumbnails - final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; + // Episode-only = all items are episodes with thumbnails + final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; - // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) - final useWideLayout = - episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); + // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) + final useWideLayout = + episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); - // Card dimensions based on hub type - const wideCardMultiplier = 1.5; - final cardWidth = useWideLayout ? baseCardWidth * wideCardMultiplier : baseCardWidth; - final posterWidth = cardWidth - 6; // 3px padding on each side - final posterHeight = useWideLayout - ? posterWidth * - (9 / 16) // 16:9 for wide layout - : posterWidth * 1.5; // 2:3 for poster layout + // Card dimensions based on hub type + const wideCardMultiplier = 1.5; + final cardWidth = useWideLayout ? baseCardWidth * wideCardMultiplier : baseCardWidth; + final posterWidth = cardWidth - 6; // 3px padding on each side + final posterHeight = useWideLayout + ? posterWidth * + (9 / 16) // 16:9 for wide layout + : posterWidth * 1.5; // 2:3 for poster layout - final containerHeight = posterHeight + 33; - final focusBorderWidth = FocusTheme.focusBorderWidth; - final focusExtra = focusBorderWidth * 2; // border on both sides - _itemExtent = cardWidth + focusExtra + 4; + final containerHeight = posterHeight + 33; + final focusBorderWidth = FocusTheme.focusBorderWidth; + final focusExtra = focusBorderWidth * 2; // border on both sides + _itemExtent = cardWidth + focusExtra + 4; - return SizedBox( - height: containerHeight + focusExtra + 4, // extra for scale + border top/bottom - child: HorizontalScrollWithArrows( - controller: _scrollController, - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - padding: widget.inset - ? const EdgeInsets.symmetric(vertical: 2) - : const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length, - itemBuilder: (context, index) { - final isItemFocused = hasFocus && index == _focusedIndex; + return SizedBox( + height: containerHeight + focusExtra + 4, // extra for scale + border top/bottom + child: HorizontalScrollWithArrows( + controller: _scrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: widget.inset + ? const EdgeInsets.symmetric(vertical: 2) + : const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length, + itemBuilder: (context, index) { + final isItemFocused = hasFocus && index == _focusedIndex; + + // "View All" card at end + if (index == widget.hub.items.length) { + return Padding( + padding: widget.inset + ? const EdgeInsets.only(right: 4) + : const EdgeInsets.symmetric(horizontal: 2), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isItemFocused, + onTap: () { + _onItemTapped(index); + _navigateToHubDetail(context); + }, + child: SizedBox( + width: 80, + height: containerHeight - 10, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.arrow_forward_rounded, + size: 32, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + const SizedBox(height: 4), + Text( + t.common.viewAll, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ), + ), + ); + } + + final item = widget.hub.items[index]; - // "View All" card at end - if (index == widget.hub.items.length) { return Padding( padding: widget.inset ? const EdgeInsets.only(right: 4) @@ -449,66 +490,27 @@ class HubSectionState extends State { child: FocusBuilders.buildLockedFocusWrapper( context: context, isFocused: isItemFocused, - onTap: () { - _onItemTapped(index); - _navigateToHubDetail(context); - }, - child: SizedBox( - width: 80, - height: containerHeight - 10, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Symbols.arrow_forward_rounded, - size: 32, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - ), - const SizedBox(height: 4), - Text( - t.common.viewAll, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), - ), + onTap: () => _onItemTapped(index), + onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(), + child: MediaCard( + key: _getMediaCardKey(index), + item: item, + width: cardWidth, + height: posterHeight, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + forceGridMode: true, + isInContinueWatching: widget.isInContinueWatching, + mixedHubContext: isMixedHub, ), ), ); - } - - final item = widget.hub.items[index]; - - return Padding( - padding: widget.inset - ? const EdgeInsets.only(right: 4) - : const EdgeInsets.symmetric(horizontal: 2), - child: FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: isItemFocused, - onTap: () => _onItemTapped(index), - onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(), - child: MediaCard( - key: _getMediaCardKey(index), - item: item, - width: cardWidth, - height: posterHeight, - onRefresh: widget.onRefresh, - onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, - forceGridMode: true, - isInContinueWatching: widget.isInContinueWatching, - mixedHubContext: isMixedHub, - ), - ), - ); - }, + }, + ), ), - ), - ); - }, + ); + }, + ), ), ) else diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 48deb628..f863b62e 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -12,9 +12,9 @@ import '../media/media_playlist.dart'; import '../mixins/context_menu_tap_mixin.dart'; import '../providers/download_provider.dart'; import '../services/download_storage_service.dart'; -import '../providers/settings_provider.dart'; -import '../screens/media_detail_screen.dart'; import '../services/settings_service.dart'; +import 'settings_builder.dart'; +import '../screens/media_detail_screen.dart'; import '../utils/content_utils.dart'; import '../utils/provider_extensions.dart'; import '../utils/formatters.dart'; @@ -163,13 +163,27 @@ class MediaCardState extends State with ContextMenuTapMixin((s) => s.viewMode); + viewMode = SettingsService.instanceOrNull!.read(SettingsService.viewMode); } final semanticLabel = _buildSemanticLabel(); @@ -185,7 +199,7 @@ class MediaCardState extends State with ContextMenuTapMixin((s) => s.libraryDensity), + density: SettingsService.instanceOrNull!.read(SettingsService.libraryDensity), isOffline: widget.isOffline, localPosterPath: localPosterPath, showServerName: widget.showServerName, @@ -337,7 +351,7 @@ class _MediaCardList extends StatelessWidget { final base = _basePosterWidth(); // For episodes with thumbnail mode, use wider width to maintain reasonable thumbnail size if (item is MediaItem) { - final mode = context.select((s) => s.episodePosterMode); + final mode = SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode); if ((item as MediaItem).usesWideAspectRatio(mode)) { return base * 1.6; // Wider for 16:9 thumbnails } @@ -349,7 +363,7 @@ class _MediaCardList extends StatelessWidget { final base = _basePosterWidth(); // For episodes with thumbnail mode, use 16:9 aspect ratio if (item is MediaItem) { - final mode = context.select((s) => s.episodePosterMode); + final mode = SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode); if ((item as MediaItem).usesWideAspectRatio(mode)) { // 16:9: height = width * 9/16 = base * 1.6 * 9/16 = base * 0.9 return base * 0.9; @@ -448,7 +462,7 @@ class _MediaCardList extends StatelessWidget { // For TV episodes, show S# (optionally with E#) if (mi.parentIndex != null && mi.index != null) { - final showEp = context.select((p) => p.showEpisodeNumberOnCards); + final showEp = SettingsService.instanceOrNull!.read(SettingsService.showEpisodeNumberOnCards); return showEp ? 'S${mi.parentIndex} E${mi.index}' : 'S${mi.parentIndex}'; } @@ -484,7 +498,7 @@ class _MediaCardList extends StatelessWidget { fontSize: _subtitleFontSize, ); final episodeTitle = mi.displaySubtitle ?? mi.displayTitle; - final showEp = context.select((p) => p.showEpisodeNumberOnCards); + final showEp = SettingsService.instanceOrNull!.read(SettingsService.showEpisodeNumberOnCards); final episodeNum = (showEp && mi.index != null) ? ' E${mi.index}' : ''; return Row( children: [ @@ -590,7 +604,7 @@ class _MediaCardList extends StatelessWidget { ], // Summary (hidden when spoiler protection is active) if (!(item is MediaItem && - context.select((s) => s.hideSpoilers) && + SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers) && (item as MediaItem).shouldHideSpoiler) && _summary() != null) ...[ Text( @@ -665,8 +679,8 @@ Widget _buildPosterImage( localFilePath: localPosterPath, ); } else if (item is MediaItem) { - final episodePosterMode = context.select((s) => s.episodePosterMode); - final hideSpoilers = context.select((s) => s.hideSpoilers); + final episodePosterMode = SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode); + final hideSpoilers = SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers); final shouldBlur = hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail; posterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext); @@ -746,7 +760,7 @@ class _MediaCardHelpers { // For episodes, show "S# · Episode Title" with clickable season link if (mi.isEpisode && mi.parentIndex != null) { final episodeTitle = mi.displaySubtitle ?? mi.displayTitle; - final showEp = context.select((p) => p.showEpisodeNumberOnCards); + final showEp = SettingsService.instanceOrNull!.read(SettingsService.showEpisodeNumberOnCards); final episodeSuffix = (showEp && mi.index != null) ? ' E${mi.index}' : ''; if (mi.parentId != null) { return Row( @@ -791,7 +805,7 @@ class _MediaCardHelpers { /// Builds watch progress overlay (checkmark for watched, progress bar for in-progress) static Widget buildWatchProgress(BuildContext context, MediaItem mi) { - final showUnwatchedCount = context.select((s) => s.showUnwatchedCount); + final showUnwatchedCount = SettingsService.instanceOrNull!.read(SettingsService.showUnwatchedCount); final hasActiveProgress = mi.viewOffsetMs != null && mi.durationMs != null && mi.viewOffsetMs! > 0 && mi.viewOffsetMs! < mi.durationMs!; diff --git a/lib/widgets/setting_tile.dart b/lib/widgets/setting_tile.dart new file mode 100644 index 00000000..df0cacff --- /dev/null +++ b/lib/widgets/setting_tile.dart @@ -0,0 +1,346 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../screens/settings/settings_utils.dart'; +import '../services/settings_service.dart'; +import 'app_icon.dart'; +import 'settings_section.dart'; + +/// Reactive setting tiles bound to a [Pref] via [SettingsService.listenable]. +/// Eliminates the field-mirror + setState + manual reload pattern that used to +/// surround every settings row. + +class _TileBase { + static SettingsService get _svc => SettingsService.instanceOrNull!; +} + +/// SwitchListTile bound to a [Pref]. +class SettingSwitchTile extends StatelessWidget { + final Pref pref; + final IconData icon; + final String title; + final String? subtitle; + final FutureOr Function(bool)? onAfterWrite; + final bool enabled; + final FocusNode? focusNode; + + const SettingSwitchTile({ + super.key, + required this.pref, + required this.icon, + required this.title, + this.subtitle, + this.onAfterWrite, + this.enabled = true, + this.focusNode, + }); + + @override + Widget build(BuildContext context) { + final svc = _TileBase._svc; + return ValueListenableBuilder( + valueListenable: svc.listenable(pref), + builder: (_, value, _) => SwitchListTile( + focusNode: focusNode, + secondary: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + value: value, + onChanged: enabled + ? (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + } + : null, + ), + ); + } +} + +/// Standard settings row that navigates to another screen. +class SettingNavigationTile extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final WidgetBuilder? destinationBuilder; + final VoidCallback? onTap; + final FocusNode? focusNode; + final IconData trailingIcon; + + const SettingNavigationTile({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.destinationBuilder, + this.onTap, + this.focusNode, + this.trailingIcon = Symbols.chevron_right_rounded, + }) : assert(destinationBuilder != null || onTap != null); + + @override + Widget build(BuildContext context) { + return ListTile( + focusNode: focusNode, + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: AppIcon(trailingIcon, fill: 1), + onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)), + ); + } +} + +/// ListTile that opens [showNumericInputDialog] and writes back. +class SettingNumberTile extends StatelessWidget { + final Pref pref; + final IconData icon; + final String title; + final String Function(int) subtitleBuilder; + final String labelText; + final String suffixText; + final int min; + final int max; + final FutureOr Function(int)? onAfterWrite; + + const SettingNumberTile({ + super.key, + required this.pref, + required this.icon, + required this.title, + required this.subtitleBuilder, + required this.labelText, + required this.suffixText, + required this.min, + required this.max, + this.onAfterWrite, + }); + + @override + Widget build(BuildContext context) { + final svc = _TileBase._svc; + return ValueListenableBuilder( + valueListenable: svc.listenable(pref), + builder: (_, value, _) => ListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: Text(subtitleBuilder(value)), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showNumericInputDialog( + context: context, + title: title, + labelText: labelText, + suffixText: suffixText, + min: min, + max: max, + currentValue: value, + onSave: (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + }, + ), + ), + ); + } +} + +/// ListTile that opens [showSelectionDialog] and writes the chosen value. +/// [encode]/[decode] map between the [Pref] storage type and the option +/// type [T] (e.g. enum-stored-as-string preset → [TranscodeQualityPreset]). +class SettingSelectionTile extends StatelessWidget { + final Pref pref; + final IconData icon; + final String title; + final String Function(T) subtitleBuilder; + final List> options; + final T Function(S) decode; + final S Function(T) encode; + final FutureOr Function(T)? onAfterWrite; + + const SettingSelectionTile({ + super.key, + required this.pref, + required this.icon, + required this.title, + required this.subtitleBuilder, + required this.options, + required this.decode, + required this.encode, + this.onAfterWrite, + }); + + @override + Widget build(BuildContext context) { + final svc = _TileBase._svc; + return ValueListenableBuilder( + valueListenable: svc.listenable(pref), + builder: (_, raw, _) { + final value = decode(raw); + return ListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: Text(subtitleBuilder(value)), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () async { + final picked = await showSelectionDialog( + context: context, + title: title, + options: options, + currentValue: value, + ); + if (picked == null) return; + await svc.write(pref, encode(picked)); + final callback = onAfterWrite; + if (callback != null) await callback(picked); + }, + ); + }, + ); + } +} + +/// ListTile that opens [showRegexInputDialog] for a [Pref]. +class SettingRegexTile extends StatelessWidget { + final Pref pref; + final IconData icon; + final String title; + final String subtitle; + final String defaultValue; + final FutureOr Function(String)? onAfterWrite; + + const SettingRegexTile({ + super.key, + required this.pref, + required this.icon, + required this.title, + required this.subtitle, + required this.defaultValue, + this.onAfterWrite, + }); + + @override + Widget build(BuildContext context) { + final svc = _TileBase._svc; + return ValueListenableBuilder( + valueListenable: svc.listenable(pref), + builder: (_, value, _) => ListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: Text(subtitle), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showRegexInputDialog( + context: context, + title: title, + currentValue: value, + defaultValue: defaultValue, + onSave: (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + }, + ), + ), + ); + } +} + +/// SegmentedSetting bound to a [Pref]. Use [encode]/[decode] when the +/// stored type differs from the segment type (e.g. bool stored, segments +/// over enum). +class SettingSegmentedTile extends StatelessWidget { + final Pref pref; + final IconData icon; + final String title; + final List> segments; + final T Function(S) decode; + final S Function(T) encode; + final FutureOr Function(T)? onAfterWrite; + + const SettingSegmentedTile({ + super.key, + required this.pref, + required this.icon, + required this.title, + required this.segments, + required this.decode, + required this.encode, + this.onAfterWrite, + }); + + @override + Widget build(BuildContext context) { + final svc = _TileBase._svc; + return ValueListenableBuilder( + valueListenable: svc.listenable(pref), + builder: (_, raw, _) { + final value = decode(raw); + return SegmentedSetting( + icon: icon, + title: title, + segments: segments, + selected: value, + onChanged: (v) async { + await svc.write(pref, encode(v)); + final callback = onAfterWrite; + if (callback != null) await callback(v); + }, + ); + }, + ); + } +} + +/// ListTile that opens [showColorInputDialog] for a hex-string [Pref]. +/// Trailing widget is a small color swatch. +class SettingColorTile extends StatelessWidget { + final Pref pref; + final IconData icon; + final String title; + final String? subtitle; + final FutureOr Function(String hex)? onAfterWrite; + + const SettingColorTile({ + super.key, + required this.pref, + required this.icon, + required this.title, + this.subtitle, + this.onAfterWrite, + }); + + @override + Widget build(BuildContext context) { + final svc = _TileBase._svc; + return ValueListenableBuilder( + valueListenable: svc.listenable(pref), + builder: (_, hex, _) => ListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: hexToColor(hex), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + ), + onTap: () => showColorInputDialog( + context: context, + title: title, + currentHex: hex, + onSave: (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/settings_builder.dart b/lib/widgets/settings_builder.dart new file mode 100644 index 00000000..a7c319ba --- /dev/null +++ b/lib/widgets/settings_builder.dart @@ -0,0 +1,52 @@ +import 'package:flutter/widgets.dart'; + +import '../services/settings_service.dart'; + +/// Non-reactive one-shot read of [pref] from the singleton [SettingsService]. +/// Use for callbacks and event handlers that need the current value but don't +/// need to rebuild on change. For reactive reads in build methods, prefer +/// [SettingValueBuilder] / [SettingsBuilder] so only the dependent subtree rebuilds. +extension SettingsContextRead on BuildContext { + T settingsRead(Pref pref) => SettingsService.instanceOrNull!.read(pref); + Future settingsWrite(Pref pref, T value) => SettingsService.instanceOrNull!.write(pref, value); +} + +/// Rebuild [builder] when any of [prefs] changes. Use when a widget's output +/// depends on multiple settings (conditional visibility, derived values). +/// Inside [builder], read with [SettingsService.read] directly — the rebuild +/// is already wired through. +class SettingsBuilder extends StatelessWidget { + final List> prefs; + final WidgetBuilder builder; + + const SettingsBuilder({super.key, required this.prefs, required this.builder}); + + @override + Widget build(BuildContext context) { + final svc = SettingsService.instanceOrNull!; + return ListenableBuilder( + listenable: Listenable.merge(prefs.map(svc.listenableOf).toList(growable: false)), + builder: (context, _) => builder(context), + ); + } +} + +/// Single-pref wrapper around [ValueListenableBuilder] keyed by a [Pref]. +/// Equivalent to `ValueListenableBuilder(valueListenable: svc.listenable(pref))` +/// but reads cleaner at call sites that don't already hold the service. +class SettingValueBuilder extends StatelessWidget { + final Pref pref; + final Widget Function(BuildContext, T value, Widget? child) builder; + final Widget? child; + + const SettingValueBuilder({super.key, required this.pref, required this.builder, this.child}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: SettingsService.instanceOrNull!.listenable(pref), + builder: builder, + child: child, + ); + } +} diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 0b22dca0..db5f8688 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -13,7 +13,7 @@ import '../media/media_library.dart'; import '../navigation/navigation_tabs.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; -import '../providers/settings_provider.dart'; +import '../services/settings_service.dart'; import '../utils/platform_detector.dart'; import '../utils/library_grouping.dart'; import '../providers/multi_server_provider.dart'; @@ -432,21 +432,23 @@ class SideNavigationRailState extends State { final isCollapsed = !_shouldExpand; final hasLiveTv = context.watch().hasLiveTv; - // Server grouping: only when multi-server AND the user-facing toggle is on. - final groupByServerSetting = context.select((p) => p.groupLibrariesByServer); - final showServerHeaders = serverIds.length > 1 && groupByServerSetting; - - final focusOrder = _buildFocusOrder( - visibleLibraries, - hiddenLibraries, - hasLiveTv: hasLiveTv, - showServerHeaders: showServerHeaders, - ); - - // Listen to fullscreen changes for macOS + // Listen to fullscreen + groupLibrariesByServer setting so the rail + // rebuilds when the user toggles "Group libraries by server" in Appearance. return ListenableBuilder( - listenable: FullscreenStateManager(), + listenable: Listenable.merge([ + FullscreenStateManager(), + SettingsService.instanceOrNull!.listenable(SettingsService.groupLibrariesByServer), + ]), builder: (context, _) { + // Server grouping: only when multi-server AND the user-facing toggle is on. + final groupByServerSetting = SettingsService.instanceOrNull!.read(SettingsService.groupLibrariesByServer); + final showServerHeaders = serverIds.length > 1 && groupByServerSetting; + final focusOrder = _buildFocusOrder( + visibleLibraries, + hiddenLibraries, + hasLiveTv: hasLiveTv, + showServerHeaders: showServerHeaders, + ); return TapRegion( onTapOutside: (_) { if (_isTouchExpanded) { diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index befc9f5f..f87b2029 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -1,41 +1,6 @@ part of '../video_controls.dart'; extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { - Future _loadSeekTimes() async { - final settingsService = await SettingsService.getInstance(); - if (mounted) { - _setControlsState(() { - _seekTimeSmall = settingsService.read(SettingsService.seekTimeSmall); - _rewindOnResume = settingsService.read(SettingsService.rewindOnResume); - _audioSyncOffset = settingsService.read(SettingsService.audioSyncOffset); - _subtitleSyncOffset = settingsService.read(SettingsService.subtitleSyncOffset); - _isRotationLocked = settingsService.read(SettingsService.rotationLocked); - _autoSkipIntro = settingsService.read(SettingsService.autoSkipIntro); - _autoSkipCredits = settingsService.read(SettingsService.autoSkipCredits); - _autoSkipDelay = settingsService.read(SettingsService.autoSkipDelay); - _videoPlayerNavigationEnabled = settingsService.read(SettingsService.videoPlayerNavigationEnabled); - _showPerformanceOverlay = settingsService.read(SettingsService.showPerformanceOverlay); - _autoHidePerformanceOverlay = settingsService.read(SettingsService.autoHidePerformanceOverlay); - _clickVideoTogglesPlayback = settingsService.read(SettingsService.clickVideoTogglesPlayback); - }); - - // Focus play/pause if navigation is now enabled and controls are visible - // (handles case where initState focus attempt failed due to async settings load) - if (_videoPlayerNavigationEnabled && _showControls) { - _focusPlayPauseIfKeyboardMode(); - } - - // Apply rotation lock setting - if (_isRotationLocked) { - unawaited( - SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]), - ); - } else { - unawaited(SystemChrome.setPreferredOrientations(DeviceOrientation.values)); - } - } - } - void _toggleSubtitles() { final currentTrack = widget.player.state.track.subtitle; // No-op if no subtitle track is selected @@ -155,22 +120,13 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { onAudioTrackChanged: widget.onAudioTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged, onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, - onLoadSeekTimes: () async { - if (mounted) { - await _loadSeekTimes(); - } - }, + onLoadSeekTimes: null, onCancelAutoHide: () => _hideTimer?.cancel(), onStartAutoHide: _startHideTimer, - onSyncOffsetChanged: (propertyName, offset) { - _setControlsState(() { - if (propertyName == 'sub-delay') { - _subtitleSyncOffset = offset; - } else { - _audioSyncOffset = offset; - } - }); - }, + // Sync offsets are now driven by listenable rebuilds — the sheet writes + // to SettingsService and the parent re-reads via `_audioSyncOffset` / + // `_subtitleSyncOffset` getters. Callback kept for sheet API compat. + onSyncOffsetChanged: null, serverId: widget.metadata.serverId ?? '', shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index ffefdfbd..f0f1bd16 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -201,22 +201,19 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { _cancelAutoSkipTimer(); } - void _toggleRotationLock() async { - _setControlsState(() { - _isRotationLocked = !_isRotationLocked; - }); + /// Apply preferred orientations for the given lock state. Wired to + /// [SettingsService.rotationLocked] via [bindEffect] so any change — from + /// this toggle or from the settings screen — fires the same SystemChrome call. + void _applyRotationLock(bool locked) { + unawaited( + SystemChrome.setPreferredOrientations( + locked ? const [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight] : DeviceOrientation.values, + ), + ); + } - // Save to settings - final settingsService = await SettingsService.getInstance(); - await settingsService.write(SettingsService.rotationLocked, _isRotationLocked); - - if (_isRotationLocked) { - // Locked: Allow landscape orientations only - await SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); - } else { - // Unlocked: Allow all orientations including portrait - await SystemChrome.setPreferredOrientations(DeviceOrientation.values); - } + void _toggleRotationLock() { + unawaited(_settings.write(SettingsService.rotationLocked, !_isRotationLocked)); } void _toggleScreenLock() { diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 6d412279..c9bc35b4 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; @@ -75,6 +76,37 @@ class _SettingsMenuItem extends StatelessWidget { } } +class _SettingsToggleItem extends StatelessWidget { + final Pref pref; + final IconData icon; + final String title; + final FutureOr Function(bool value)? onAfterWrite; + + const _SettingsToggleItem({required this.pref, required this.icon, required this.title, this.onAfterWrite}); + + @override + Widget build(BuildContext context) { + final settings = SettingsService.instanceOrNull!; + return ValueListenableBuilder( + valueListenable: settings.listenable(pref), + builder: (context, value, _) { + Future write(bool next) async { + await settings.write(pref, next); + final callback = onAfterWrite; + if (callback != null) await callback(next); + } + + return FocusableListTile( + leading: AppIcon(icon, fill: 1, color: value ? Colors.amber : tokens(context).textMuted), + title: Text(title), + trailing: Switch(value: value, onChanged: write, activeThumbColor: Colors.amber), + onTap: () => write(!value), + ); + }, + ); + } +} + /// Unified settings sheet for playback adjustments with in-sheet navigation class VideoSettingsSheet extends StatefulWidget { final Player player; @@ -132,11 +164,6 @@ class _VideoSettingsSheetState extends State { _SettingsView _currentView = _SettingsView.menu; late int _audioSyncOffset; late int _subtitleSyncOffset; - bool _enableHDR = true; - bool _showPerformanceOverlay = false; - bool _autoPlayNextEpisode = true; - bool _audioPassthrough = false; - bool _audioNormalization = false; String _dvConversionMode = 'auto'; @override @@ -144,79 +171,18 @@ class _VideoSettingsSheetState extends State { super.initState(); _audioSyncOffset = widget.audioSyncOffset; _subtitleSyncOffset = widget.subtitleSyncOffset; - _loadSettings(); + _loadDebugDvConversionMode(); } - Future _loadSettings() async { - final settings = await SettingsService.getInstance(); - final dvConversionMode = kDebugMode && Platform.isAndroid && widget.player.playerType == 'exoplayer' - ? await widget.player.getProperty('dv-conversion-mode') - : null; + Future _loadDebugDvConversionMode() async { + if (!kDebugMode || !Platform.isAndroid || widget.player.playerType != 'exoplayer') return; + final dvConversionMode = await widget.player.getProperty('dv-conversion-mode'); if (!mounted) return; setState(() { - _enableHDR = settings.read(SettingsService.enableHDR); - _showPerformanceOverlay = settings.read(SettingsService.showPerformanceOverlay); - _autoPlayNextEpisode = settings.read(SettingsService.autoPlayNextEpisode); - _audioPassthrough = settings.read(SettingsService.audioPassthrough); - _audioNormalization = settings.read(SettingsService.audioNormalization); _dvConversionMode = _normalizeDvConversionMode(dvConversionMode); }); } - Future _toggleHDR() async { - final newValue = !_enableHDR; - final settings = await SettingsService.getInstance(); - await settings.write(SettingsService.enableHDR, newValue); - if (!mounted) return; - setState(() { - _enableHDR = newValue; - }); - // Apply to player immediately - await widget.player.setProperty('hdr-enabled', newValue ? 'yes' : 'no'); - } - - Future _togglePerformanceOverlay() async { - final newValue = !_showPerformanceOverlay; - final settings = await SettingsService.getInstance(); - await settings.write(SettingsService.showPerformanceOverlay, newValue); - if (!mounted) return; - setState(() { - _showPerformanceOverlay = newValue; - }); - } - - Future _toggleAutoPlayNextEpisode() async { - final newValue = !_autoPlayNextEpisode; - final settings = await SettingsService.getInstance(); - await settings.write(SettingsService.autoPlayNextEpisode, newValue); - if (!mounted) return; - setState(() { - _autoPlayNextEpisode = newValue; - }); - } - - Future _toggleAudioPassthrough() async { - final newValue = !_audioPassthrough; - final settings = await SettingsService.getInstance(); - await settings.write(SettingsService.audioPassthrough, newValue); - if (!mounted) return; - setState(() { - _audioPassthrough = newValue; - }); - await widget.player.setAudioPassthrough(newValue); - } - - Future _toggleAudioNormalization() async { - final newValue = !_audioNormalization; - final settings = await SettingsService.getInstance(); - await settings.write(SettingsService.audioNormalization, newValue); - if (!mounted) return; - setState(() { - _audioNormalization = newValue; - }); - await widget.player.setProperty('af', newValue ? 'loudnorm=I=-14:TP=-3:LRA=4' : ''); - } - Future _setDebugDvConversionMode(String mode) async { await widget.player.setProperty('dv-conversion-mode', mode); if (!mounted) return; @@ -268,7 +234,7 @@ class _VideoSettingsSheetState extends State { initialOffset: initialOffset, sliderFocusNode: sliderFocusNode, onOffsetChanged: (offset) async { - final settings = await SettingsService.getInstance(); + final settings = SettingsService.instanceOrNull!; if (isSubtitle) { await settings.write(SettingsService.subtitleSyncOffset, offset); } else { @@ -419,31 +385,18 @@ class _VideoSettingsSheetState extends State { // HDR Toggle (iOS, macOS, and Windows) if (Platform.isIOS || Platform.isMacOS || Platform.isWindows) - FocusableListTile( - leading: AppIcon( - Symbols.hdr_strong_rounded, - fill: 1, - color: _enableHDR ? Colors.amber : tokens(context).textMuted, - ), - title: Text(t.videoSettings.hdr), - trailing: Switch(value: _enableHDR, onChanged: (_) => _toggleHDR(), activeThumbColor: Colors.amber), - onTap: _toggleHDR, + _SettingsToggleItem( + pref: SettingsService.enableHDR, + icon: Symbols.hdr_strong_rounded, + title: t.videoSettings.hdr, + onAfterWrite: (value) => widget.player.setProperty('hdr-enabled', value ? 'yes' : 'no'), ), // Auto-Play Next Episode Toggle - FocusableListTile( - leading: AppIcon( - Symbols.skip_next_rounded, - fill: 1, - color: _autoPlayNextEpisode ? Colors.amber : tokens(context).textMuted, - ), - title: Text(t.videoControls.autoPlayNext), - trailing: Switch( - value: _autoPlayNextEpisode, - onChanged: (_) => _toggleAutoPlayNextEpisode(), - activeThumbColor: Colors.amber, - ), - onTap: _toggleAutoPlayNextEpisode, + _SettingsToggleItem( + pref: SettingsService.autoPlayNextEpisode, + icon: Symbols.skip_next_rounded, + title: t.videoControls.autoPlayNext, ), // Audio Output Device (Desktop only) @@ -467,36 +420,20 @@ class _VideoSettingsSheetState extends State { // Audio Passthrough (Desktop only) if (isDesktop) - FocusableListTile( - leading: AppIcon( - Symbols.surround_sound_rounded, - fill: 1, - color: _audioPassthrough ? Colors.amber : tokens(context).textMuted, - ), - title: Text(t.videoSettings.audioPassthrough), - trailing: Switch( - value: _audioPassthrough, - onChanged: (_) => _toggleAudioPassthrough(), - activeThumbColor: Colors.amber, - ), - onTap: _toggleAudioPassthrough, + _SettingsToggleItem( + pref: SettingsService.audioPassthrough, + icon: Symbols.surround_sound_rounded, + title: t.videoSettings.audioPassthrough, + onAfterWrite: widget.player.setAudioPassthrough, ), // Audio Normalization (MPV only) if (widget.player.playerType == 'mpv') - FocusableListTile( - leading: AppIcon( - Symbols.graphic_eq_rounded, - fill: 1, - color: _audioNormalization ? Colors.amber : tokens(context).textMuted, - ), - title: Text(t.videoSettings.audioNormalization), - trailing: Switch( - value: _audioNormalization, - onChanged: (_) => _toggleAudioNormalization(), - activeThumbColor: Colors.amber, - ), - onTap: _toggleAudioNormalization, + _SettingsToggleItem( + pref: SettingsService.audioNormalization, + icon: Symbols.graphic_eq_rounded, + title: t.videoSettings.audioNormalization, + onAfterWrite: (value) => widget.player.setProperty('af', value ? 'loudnorm=I=-14:TP=-3:LRA=4' : ''), ), // Shader Preset (MPV only) @@ -533,19 +470,10 @@ class _VideoSettingsSheetState extends State { ), // Performance Overlay Toggle - FocusableListTile( - leading: AppIcon( - Symbols.analytics_rounded, - fill: 1, - color: _showPerformanceOverlay ? Colors.amber : tokens(context).textMuted, - ), - title: Text(t.videoSettings.performanceOverlay), - trailing: Switch( - value: _showPerformanceOverlay, - onChanged: (_) => _togglePerformanceOverlay(), - activeThumbColor: Colors.amber, - ), - onTap: _togglePerformanceOverlay, + _SettingsToggleItem( + pref: SettingsService.showPerformanceOverlay, + icon: Symbols.analytics_rounded, + title: t.videoSettings.performanceOverlay, ), if (kDebugMode && Platform.isAndroid && widget.player.playerType == 'exoplayer') @@ -628,8 +556,7 @@ class _VideoSettingsSheetState extends State { onTap: () async { await widget.player.setRate(speed); // Save as default playback speed - final settings = await SettingsService.getInstance(); - await settings.write(SettingsService.defaultPlaybackSpeed, speed); + await SettingsService.instanceOrNull!.write(SettingsService.defaultPlaybackSpeed, speed); if (context.mounted) { OverlaySheetController.of(context).close(); // Close sheet after selection } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index ca3e77e4..841f59cd 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -21,6 +21,7 @@ import '../../services/macos_window_service.dart'; import '../../services/pip_service.dart'; import 'package:window_manager/window_manager.dart'; +import '../../mixins/settings_effect_mixin.dart'; import '../../mpv/mpv.dart'; import '../overlay_sheet.dart'; import '../../focus/dpad_navigator.dart'; @@ -336,7 +337,7 @@ class PlexVideoControls extends StatefulWidget { State createState() => _PlexVideoControlsState(); } -class _PlexVideoControlsState extends State with WindowListener, WidgetsBindingObserver { +class _PlexVideoControlsState extends State with WindowListener, SettingsEffectMixin { bool _showControls = true; bool _forceShowControls = false; bool _isLoadingExtras = false; @@ -347,15 +348,19 @@ class _PlexVideoControlsState extends State with WindowListen bool _isAlwaysOnTop = false; late final FocusNode _focusNode; KeyboardShortcutsService? _keyboardService; - int _seekTimeSmall = 10; // Default, loaded from settings - int _rewindOnResume = 0; // Default, loaded from settings - int _audioSyncOffset = 0; // Default, loaded from settings - int _subtitleSyncOffset = 0; // Default, loaded from settings - bool _isRotationLocked = true; // Default locked (landscape only) + // Live settings — read through the service so a change anywhere in the app + // reflects here without a manual reload. UI rebuilds are wired via + // [bindRebuild] in [initState]; side effects (rotation, sync) via [bindEffect]. + SettingsService get _settings => SettingsService.instanceOrNull!; + int get _seekTimeSmall => _settings.read(SettingsService.seekTimeSmall); + int get _rewindOnResume => _settings.read(SettingsService.rewindOnResume); + int get _audioSyncOffset => _settings.read(SettingsService.audioSyncOffset); + int get _subtitleSyncOffset => _settings.read(SettingsService.subtitleSyncOffset); + bool get _isRotationLocked => _settings.read(SettingsService.rotationLocked); bool _isScreenLocked = false; // Touch lock during playback bool _showLockIcon = false; // Whether to show the lock overlay icon Timer? _lockIconTimer; - bool _clickVideoTogglesPlayback = false; // Default, loaded from settings + bool get _clickVideoTogglesPlayback => _settings.read(SettingsService.clickVideoTogglesPlayback); bool _isContentStripVisible = false; // Whether the swipe-up content strip is showing // GlobalKey to access DesktopVideoControls state for focus management @@ -385,19 +390,19 @@ class _PlexVideoControlsState extends State with WindowListen // Position subscription for marker tracking StreamSubscription? _positionSubscription; // Auto-skip state - bool _autoSkipIntro = false; - bool _autoSkipCredits = false; - int _autoSkipDelay = 5; + bool get _autoSkipIntro => _settings.read(SettingsService.autoSkipIntro); + bool get _autoSkipCredits => _settings.read(SettingsService.autoSkipCredits); + int get _autoSkipDelay => _settings.read(SettingsService.autoSkipDelay); Timer? _autoSkipTimer; double _autoSkipProgress = 0.0; // Skip button dismiss state bool _skipButtonDismissed = false; Timer? _skipButtonDismissTimer; // Video player navigation (use arrow keys to navigate controls) - bool _videoPlayerNavigationEnabled = false; + bool get _videoPlayerNavigationEnabled => _settings.read(SettingsService.videoPlayerNavigationEnabled); // Performance overlay - bool _showPerformanceOverlay = false; - bool _autoHidePerformanceOverlay = true; + bool get _showPerformanceOverlay => _settings.read(SettingsService.showPerformanceOverlay); + bool get _autoHidePerformanceOverlay => _settings.read(SettingsService.autoHidePerformanceOverlay); // Long-press 2x speed state bool _isLongPressing = false; // Subtitle visibility toggle state @@ -429,15 +434,34 @@ class _PlexVideoControlsState extends State with WindowListen leading: true, trailing: true, ); - _loadSeekTimes(); + // Side effects: rotation lock + focus on nav-enable. Both fire immediately + // so init wiring (orientation, focus) lives in one place. + bindEffect(SettingsService.rotationLocked, _applyRotationLock); + bindEffect(SettingsService.videoPlayerNavigationEnabled, (enabled) { + if (enabled && _showControls) _focusPlayPauseIfKeyboardMode(); + }, fireImmediately: false); + // Rebuild on any setting that affects build output (seek labels, skip + // logic, perf overlay visibility, click-toggles, etc.). + bindRebuild([ + SettingsService.seekTimeSmall, + SettingsService.rewindOnResume, + SettingsService.audioSyncOffset, + SettingsService.subtitleSyncOffset, + SettingsService.rotationLocked, + SettingsService.autoSkipIntro, + SettingsService.autoSkipCredits, + SettingsService.autoSkipDelay, + SettingsService.videoPlayerNavigationEnabled, + SettingsService.showPerformanceOverlay, + SettingsService.autoHidePerformanceOverlay, + SettingsService.clickVideoTogglesPlayback, + ]); _startHideTimer(); _initKeyboardService(); _listenToPosition(); _listenToPlayingState(); _listenToCompleted(); _checkPipSupport(); - // Add lifecycle observer to reload settings when app resumes - WidgetsBinding.instance.addObserver(this); // Add window listener for tracking fullscreen state (for button icon) if (PlatformDetector.isDesktopOS()) { windowManager.addListener(this); @@ -491,8 +515,6 @@ class _PlexVideoControlsState extends State with WindowListen if (_isLongPressing && _rateBeforeLongPress != null) { widget.player.setRate(_rateBeforeLongPress!); } - // Remove lifecycle observer - WidgetsBinding.instance.removeObserver(this); // Remove window listener and reset always-on-top if it was enabled if (PlatformDetector.isDesktopOS()) { windowManager.removeListener(this); @@ -506,14 +528,6 @@ class _PlexVideoControlsState extends State with WindowListen super.dispose(); } - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - // Reload seek times when app resumes (e.g., returning from settings) - _loadSeekTimes(); - } - } - @override void onWindowEnterFullScreen() { if (mounted) { diff --git a/lib/widgets/video_controls/widgets/volume_control.dart b/lib/widgets/video_controls/widgets/volume_control.dart index fc9df972..0c6a853b 100644 --- a/lib/widgets/video_controls/widgets/volume_control.dart +++ b/lib/widgets/video_controls/widgets/volume_control.dart @@ -54,23 +54,7 @@ class _VolumeControlState extends State { /// Volume step size for keyboard adjustment. static const double _volumeStep = 5.0; - /// Maximum volume from settings (100-300). - int _maxVolume = 100; - - @override - void initState() { - super.initState(); - _loadMaxVolume(); - } - - Future _loadMaxVolume() async { - final settings = await SettingsService.getInstance(); - if (mounted) { - setState(() { - _maxVolume = settings.read(SettingsService.maxVolume); - }); - } - } + SettingsService get _settings => SettingsService.instanceOrNull!; void _enterAdjustMode() { setState(() { @@ -86,10 +70,10 @@ class _VolumeControlState extends State { Future _adjustVolume(double delta) async { final currentVolume = widget.player.state.volume; - final newVolume = (currentVolume + delta).clamp(0.0, _maxVolume.toDouble()); + final maxVolume = _settings.read(SettingsService.maxVolume).toDouble(); + final newVolume = (currentVolume + delta).clamp(0.0, maxVolume); await widget.player.setVolume(newVolume); - final settings = await SettingsService.getInstance(); - await settings.write(SettingsService.volume, newVolume); + await _settings.write(SettingsService.volume, newVolume); } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { @@ -148,68 +132,72 @@ class _VolumeControlState extends State { @override Widget build(BuildContext context) { - return StreamBuilder( - stream: widget.player.streams.volume, - initialData: widget.player.state.volume, - builder: (context, snapshot) { - final volume = snapshot.data ?? 100.0; - final isMuted = volume == 0; - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + return ValueListenableBuilder( + valueListenable: _settings.listenable(SettingsService.maxVolume), + builder: (context, maxVolume, _) { + return StreamBuilder( + stream: widget.player.streams.volume, + initialData: widget.player.state.volume, + builder: (context, snapshot) { + final volume = snapshot.data ?? 100.0; + final isMuted = volume == 0; + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - final muteButton = Semantics( - label: isMuted ? t.videoControls.unmuteButton : t.videoControls.muteButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: AppIcon( - isMuted ? Symbols.volume_off_rounded : Symbols.volume_up_rounded, - fill: 1, - color: Colors.white, - ), - onPressed: () async { - final newVolume = isMuted ? 100.0 : 0.0; - await widget.player.setVolume(newVolume); - final settings = await SettingsService.getInstance(); - await settings.write(SettingsService.volume, newVolume); - }, - ), - ); + final muteButton = Semantics( + label: isMuted ? t.videoControls.unmuteButton : t.videoControls.muteButton, + button: true, + excludeSemantics: true, + child: IconButton( + icon: AppIcon( + isMuted ? Symbols.volume_off_rounded : Symbols.volume_up_rounded, + fill: 1, + color: Colors.white, + ), + onPressed: () async { + final newVolume = isMuted ? 100.0 : 0.0; + await widget.player.setVolume(newVolume); + await _settings.write(SettingsService.volume, newVolume); + }, + ), + ); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.focusNode != null) - FocusableWrapper( - focusNode: widget.focusNode, - onSelect: _enterAdjustMode, - onKeyEvent: _handleKeyEvent, - onFocusChange: _handleFocusChange, - borderRadius: 20, - autoScroll: false, - useBackgroundFocus: true, - disableScale: true, - semanticLabel: () { - if (_isAdjustMode) return t.videoControls.volumeSlider; - return isMuted ? t.videoControls.unmuteButton : t.videoControls.muteButton; - }(), - child: muteButton, - ) - else - muteButton, - const SizedBox(width: 8), - _buildVolumeSlider(volume, isKeyboardMode), - ], + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.focusNode != null) + FocusableWrapper( + focusNode: widget.focusNode, + onSelect: _enterAdjustMode, + onKeyEvent: _handleKeyEvent, + onFocusChange: _handleFocusChange, + borderRadius: 20, + autoScroll: false, + useBackgroundFocus: true, + disableScale: true, + semanticLabel: () { + if (_isAdjustMode) return t.videoControls.volumeSlider; + return isMuted ? t.videoControls.unmuteButton : t.videoControls.muteButton; + }(), + child: muteButton, + ) + else + muteButton, + const SizedBox(width: 8), + _buildVolumeSlider(volume, isKeyboardMode, maxVolume), + ], + ); + }, ); }, ); } - Widget _buildVolumeSlider(double volume, bool isKeyboardMode) { - final maxVolumeDouble = _maxVolume.toDouble(); + Widget _buildVolumeSlider(double volume, bool isKeyboardMode, int maxVolume) { + final maxVolumeDouble = maxVolume.toDouble(); // Calculate 100% marker position as fraction of slider width // Only show marker if max volume > 100 - final showMarker = _maxVolume > 100; + final showMarker = maxVolume > 100; final markerPosition = showMarker ? (100.0 / maxVolumeDouble) : 0.0; return Listener( @@ -260,8 +248,7 @@ class _VolumeControlState extends State { widget.player.setVolume(value); }, onChangeEnd: (value) async { - final settings = await SettingsService.getInstance(); - await settings.write(SettingsService.volume, value); + await _settings.write(SettingsService.volume, value); }, activeColor: Colors.white, inactiveColor: Colors.white.withValues(alpha: 0.3), diff --git a/test/providers/settings_provider_test.dart b/test/providers/settings_provider_test.dart deleted file mode 100644 index 648f9b47..00000000 --- a/test/providers/settings_provider_test.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/transcode_quality_preset.dart'; -import 'package:plezy/providers/settings_provider.dart'; -import 'package:plezy/services/base_shared_preferences_service.dart'; -import 'package:plezy/services/settings_service.dart'; - -import '../test_helpers/prefs.dart'; - -void main() { - setUp(resetSharedPreferencesForTest); - - group('SettingsProvider', () { - test('starts uninitialized and exposes defaults', () { - final p = SettingsProvider(); - expect(p.isInitialized, isFalse); - expect(p.isReady, isFalse); - // Pre-init getters fall back to declared defaults. - expect(p.libraryDensity, LibraryDensity.defaultValue); - expect(p.viewMode, ViewMode.grid); - expect(p.episodePosterMode, EpisodePosterMode.episodeThumbnail); - expect(p.showHeroSection, isTrue); - expect(p.useGlobalHubs, isTrue); - expect(p.showServerNameOnHubs, isFalse); - expect(p.hideSpoilers, isFalse); - expect(p.defaultQualityPreset, TranscodeQualityPreset.original); - p.dispose(); - }); - - test('ensureInitialized completes and flips isInitialized', () async { - final p = SettingsProvider(); - expect(p.isInitialized, isFalse); - await p.ensureInitialized(); - expect(p.isInitialized, isTrue); - expect(p.isReady, isTrue); - p.dispose(); - }); - - test('setLibraryDensity clamps and persists', () async { - final p = SettingsProvider(); - await p.ensureInitialized(); - - var notified = 0; - p.addListener(() => notified++); - - // Above max → clamped to max. - await p.setLibraryDensity(LibraryDensity.max + 5); - expect(p.libraryDensity, LibraryDensity.max); - expect(notified, 1); - - // Below min → clamped to min. - await p.setLibraryDensity(LibraryDensity.min - 5); - expect(p.libraryDensity, LibraryDensity.min); - expect(notified, 2); - - // Verify persisted directly via the service. - final svc = await SettingsService.getInstance(); - expect(svc.read(SettingsService.libraryDensity), LibraryDensity.min); - - p.dispose(); - }); - - test('setShowHeroSection toggles, notifies, and is a no-op for same value', () async { - final p = SettingsProvider(); - await p.ensureInitialized(); - - var notified = 0; - p.addListener(() => notified++); - - expect(p.showHeroSection, isTrue); - await p.setShowHeroSection(false); - expect(p.showHeroSection, isFalse); - expect(notified, 1); - - // Same value → no notify. - await p.setShowHeroSection(false); - expect(notified, 1); - - // Flip back → notify again. - await p.setShowHeroSection(true); - expect(p.showHeroSection, isTrue); - expect(notified, 2); - - p.dispose(); - }); - - test('setDefaultQualityPreset round-trips through TranscodeQualityPreset', () async { - final p = SettingsProvider(); - await p.ensureInitialized(); - - await p.setDefaultQualityPreset(TranscodeQualityPreset.p1080_10mbps); - expect(p.defaultQualityPreset, TranscodeQualityPreset.p1080_10mbps); - - // Verify the underlying string storage uses the storageKey. - final svc = await SettingsService.getInstance(); - expect(svc.read(SettingsService.defaultQualityPreset), TranscodeQualityPreset.p1080_10mbps.storageKey); - - // Round-trip a different preset. - await p.setDefaultQualityPreset(TranscodeQualityPreset.p720_2mbps); - expect(p.defaultQualityPreset, TranscodeQualityPreset.p720_2mbps); - - p.dispose(); - }); - - test('setViewMode persists enum by name', () async { - final p = SettingsProvider(); - await p.ensureInitialized(); - - expect(p.viewMode, ViewMode.grid); - await p.setViewMode(ViewMode.list); - expect(p.viewMode, ViewMode.list); - - final svc = await SettingsService.getInstance(); - expect(svc.read(SettingsService.viewMode), ViewMode.list); - - p.dispose(); - }); - - test('reload re-reads after external mutation', () async { - final p = SettingsProvider(); - await p.ensureInitialized(); - expect(p.hideSpoilers, isFalse); - - // Mutate via the service directly (simulates an import / reset). - final svc = await SettingsService.getInstance(); - await svc.write(SettingsService.hideSpoilers, true); - - var notified = 0; - p.addListener(() => notified++); - - await p.reload(); - expect(p.hideSpoilers, isTrue); - expect(notified, 1); - - p.dispose(); - }); - - test('persists across provider instances via SharedPreferences', () async { - final first = SettingsProvider(); - await first.ensureInitialized(); - await first.setShowNavBarLabels(false); - await first.setLibraryDensity(5); - first.dispose(); - - // Reset only the cached singleton — backing store is preserved. - BaseSharedPreferencesService.resetForTesting(); - - final second = SettingsProvider(); - await second.ensureInitialized(); - expect(second.showNavBarLabels, isFalse); - expect(second.libraryDensity, 5); - second.dispose(); - }); - - test('safeNotifyListeners no-ops after dispose', () async { - final p = SettingsProvider(); - await p.ensureInitialized(); - p.dispose(); - // Should not throw — reload calls safeNotifyListeners under the hood. - await p.reload(); - }); - }); -} diff --git a/test/services/settings_service_test.dart b/test/services/settings_service_test.dart index 084553cd..3705c348 100644 --- a/test/services/settings_service_test.dart +++ b/test/services/settings_service_test.dart @@ -1,7 +1,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/trackers/tracker_constants.dart'; + +import '../test_helpers/prefs.dart'; void main() { + setUp(() { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + }); + group('SettingsService.parseMpvConfigText', () { test('parses plain key=value lines', () { final out = SettingsService.parseMpvConfigText('hwdec=auto\nvolume=100'); @@ -52,4 +60,39 @@ void main() { expect(SettingsService.parseMpvConfigText(''), isEmpty); }); }); + + group('SettingsService listenables', () { + test('refreshListenables updates active prefs outside the resettable surface', () async { + final settings = await SettingsService.getInstance(); + final crashReporting = settings.listenable(SettingsService.crashReporting); + + expect(crashReporting.value, isTrue); + + await settings.prefs.setBool(SettingsService.crashReporting.key, false); + expect(crashReporting.value, isTrue); + + settings.refreshListenables(); + + expect(crashReporting.value, isFalse); + }); + + test('resetAllSettings refreshes active dynamic tracker prefs', () async { + final settings = await SettingsService.getInstance(); + final modePref = SettingsService.trackerFilterModePref(TrackerService.trakt); + final idsPref = SettingsService.trackerFilterIdsPref(TrackerService.trakt); + + await settings.write(modePref, TrackerLibraryFilterMode.whitelist); + await settings.write(idsPref, ['library-1']); + final mode = settings.listenable(modePref); + final ids = settings.listenable(idsPref); + + expect(mode.value, TrackerLibraryFilterMode.whitelist); + expect(ids.value, ['library-1']); + + await settings.resetAllSettings(); + + expect(mode.value, TrackerLibraryFilterMode.blacklist); + expect(ids.value, isEmpty); + }); + }); }