From 145881318e8d190d9b2f954ed86f03a39739f9af Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:47:06 +0200 Subject: [PATCH] fix: harden server-scoped state --- lib/main.dart | 28 +++++-- lib/media/ids.dart | 16 +++- lib/mixins/server_bound_media_mixin.dart | 9 ++- lib/mixins/settings_effect_mixin.dart | 4 +- lib/profiles/active_profile_provider.dart | 7 +- lib/profiles/plex_home_service.dart | 5 +- lib/providers/download_provider.dart | 76 ++----------------- lib/providers/hidden_libraries_provider.dart | 15 ++-- lib/providers/libraries_provider.dart | 7 +- lib/providers/offline_mode_provider.dart | 25 ++++-- lib/providers/playback_state_provider.dart | 22 +++--- lib/providers/user_profile_provider.dart | 2 + .../watch_state_overlay_provider.dart | 15 ++-- lib/screens/discover_screen.dart | 6 +- lib/screens/downloads/downloads_screen.dart | 2 +- .../focusable_detail_screen_mixin.dart | 4 +- lib/screens/hub_detail_screen.dart | 2 +- lib/screens/libraries/folder_tree_item.dart | 2 +- .../libraries/tabs/library_browse_tab.dart | 17 +++-- .../tabs/library_collections_tab.dart | 2 +- .../libraries/tabs/library_playlists_tab.dart | 2 +- .../tabs/library_recommended_tab.dart | 2 +- lib/screens/livetv/live_tv_actions_mixin.dart | 5 +- .../livetv/reorder_favorites_sheet.dart | 3 +- lib/screens/livetv/tabs/guide_tab.dart | 6 +- lib/screens/livetv/tabs/whats_on_tab.dart | 8 +- lib/screens/media_detail_screen.dart | 18 ++--- .../settings/appearance_settings_screen.dart | 4 +- .../settings/external_player_screen.dart | 6 +- lib/screens/settings/mpv_config_screen.dart | 2 +- .../settings/playback_settings_screen.dart | 4 +- lib/screens/settings/settings_screen.dart | 2 +- .../tracker_account_settings_body.dart | 2 +- .../tracker_library_filter_screen.dart | 2 +- lib/services/settings_service.dart | 9 +++ lib/services/storage_service.dart | 35 --------- lib/services/sync_rule_executor.dart | 15 +--- lib/services/trakt/trakt_sync_service.dart | 56 ++++++++------ lib/utils/deletion_notifier.dart | 7 +- lib/utils/global_key_utils.dart | 2 +- lib/utils/video_player_navigation.dart | 8 +- lib/utils/watch_state_notifier.dart | 21 ++++- lib/widgets/media_card.dart | 20 ++--- lib/widgets/setting_tile.dart | 2 +- lib/widgets/settings_builder.dart | 8 +- lib/widgets/side_navigation_rail.dart | 4 +- lib/widgets/tv_browse_rail.dart | 2 +- .../models/track_controls_state.dart | 7 +- .../video_controls/parts/track_controls.dart | 2 +- .../video_controls/sheets/track_sheet.dart | 2 +- .../sheets/video_settings_sheet.dart | 6 +- .../video_controls/video_controls.dart | 2 +- .../widgets/track_chapter_controls.dart | 2 +- .../widgets/volume_control.dart | 2 +- .../mixins/server_bound_media_mixin_test.dart | 5 +- test/providers/download_provider_test.dart | 4 +- .../playback_state_provider_test.dart | 16 ++++ .../episode_navigation_service_test.dart | 4 +- .../jellyfin_sequential_launcher_test.dart | 31 ++++++-- .../playback_progress_tracker_test.dart | 17 +++-- test/services/storage_service_test.dart | 39 +--------- test/services/sync_rule_executor_test.dart | 5 ++ .../tracker_coordinator_manual_test.dart | 4 +- test/utils/global_key_utils_test.dart | 17 ++--- test/utils/media_hub_ordering_test.dart | 32 +++++--- 65 files changed, 358 insertions(+), 360 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 83c49301..8c447567 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -206,9 +206,11 @@ Future _bootstrapApp() async { // Hook Windows native fullscreen callback (no-op elsewhere). NativeWindowService.initialize(); - futures.add(StorageService.getInstance()); + final storageFuture = StorageService.getInstance(); + futures.add(storageFuture); await Future.wait(futures); + final storage = await storageFuture; // The PLEX_TOKEN dart-define (screenshot automation) is consumed by // [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry @@ -258,7 +260,7 @@ Future _bootstrapApp() async { return const ColoredBox(color: Color(0xFF000000)); }; - runApp(const MainApp()); + runApp(MainApp(settings: settings, storage: storage)); } Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) { @@ -424,7 +426,10 @@ Future _rootPinPrompt(Profile profile, {String? errorMessage}) { } class MainApp extends StatefulWidget { - const MainApp({super.key}); + final SettingsService settings; + final StorageService storage; + + const MainApp({super.key, required this.settings, required this.storage}); @override State createState() => _MainAppState(); @@ -678,6 +683,8 @@ class _MainAppState extends State with WidgetsBindingObserver { // Expose AppDatabase + ConnectionRegistry so screens (Settings, Setup) // can manage stored Jellyfin/Plex connections without re-creating // the registry per-call site. + Provider.value(value: widget.settings), + Provider.value(value: widget.storage), Provider.value(value: _appDatabase), Provider(create: (_) => ConnectionRegistry(_appDatabase)), Provider(create: (_) => ProfileRegistry(_appDatabase)), @@ -690,6 +697,7 @@ class _MainAppState extends State with WidgetsBindingObserver { final service = PlexHomeService( connections: context.read(), profileConnections: context.read(), + storage: context.read(), ); unawaited(service.start()); return service; @@ -702,6 +710,7 @@ class _MainAppState extends State with WidgetsBindingObserver { registry: context.read(), plexHome: context.read(), connections: context.read(), + storage: context.read(), ); unawaited(provider.initialize()); return provider; @@ -829,7 +838,7 @@ class _MainAppState extends State with WidgetsBindingObserver { ), ChangeNotifierProxyProvider2( create: (context) => OfflineWatchProvider( - syncService: _offlineWatchSyncService, + syncService: context.read(), downloadProvider: context.read(), ), update: (_, syncService, downloadProvider, previous) { @@ -837,9 +846,9 @@ class _MainAppState extends State with WidgetsBindingObserver { }, ), ChangeNotifierProxyProvider2( - create: (_) => UserProfileProvider(), + create: (context) => UserProfileProvider(storageService: context.read()), update: (context, activeProfile, connections, previous) { - final provider = previous ?? UserProfileProvider(); + final provider = previous ?? UserProfileProvider(storageService: context.read()); provider.attach( connections: connections, activeProfile: activeProfile, @@ -854,10 +863,13 @@ class _MainAppState extends State with WidgetsBindingObserver { // session scoping. Hydrated and rebound by `_TrackerProfileBootstrap`. ChangeNotifierProvider(create: (context) => TraktAccountProvider()), ChangeNotifierProvider(create: (context) => TrackersProvider()), - ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true), + ChangeNotifierProvider( + create: (context) => HiddenLibrariesProvider(storageService: context.read()), + lazy: true, + ), ChangeNotifierProvider( create: (context) { - final provider = LibrariesProvider(); + final provider = LibrariesProvider(storageService: context.read()); // Reload libraries when a new server comes online. Servers bind in // waves on sign-in / profile switch and slow ones reconnect after // the initial load; without this they stay missing from the sidebar diff --git a/lib/media/ids.dart b/lib/media/ids.dart index 3f33da0b..a0ce9fc1 100644 --- a/lib/media/ids.dart +++ b/lib/media/ids.dart @@ -11,8 +11,20 @@ library; /// Identifies a media server: a Plex `machineIdentifier` or a Jellyfin server /// machine id. This is the key under which a [MediaServerClient] is registered /// and the left half of a `serverId:ratingKey` global key. -extension type const ServerId(String value) implements String {} +extension type const ServerId._(String value) implements String { + factory ServerId(String value) { + if (value.trim().isEmpty) { + throw ArgumentError.value(value, 'value', 'ServerId cannot be empty or blank'); + } + return ServerId._(value); + } + + static ServerId? tryParse(String? value) { + if (value == null || value.trim().isEmpty) return null; + return ServerId(value); + } +} /// Wraps a nullable raw id, preserving `null`. Use at boundaries where a /// `String?` from a model/storage row crosses into [ServerId]-typed code. -ServerId? serverIdOrNull(String? value) => value == null ? null : ServerId(value); +ServerId? serverIdOrNull(String? value) => ServerId.tryParse(value); diff --git a/lib/mixins/server_bound_media_mixin.dart b/lib/mixins/server_bound_media_mixin.dart index 96662e84..35199972 100644 --- a/lib/mixins/server_bound_media_mixin.dart +++ b/lib/mixins/server_bound_media_mixin.dart @@ -15,8 +15,13 @@ mixin ServerBoundMediaMixin on State { String? get serverBoundServerId => serverBoundMetadata.serverId; - String toServerBoundGlobalKey(String ratingKey, {ServerId? serverId}) => - buildGlobalKey(ServerId(serverId ?? serverBoundServerId ?? ''), ratingKey); + String toServerBoundGlobalKey(String ratingKey, {ServerId? serverId}) { + final resolved = serverId ?? serverIdOrNull(serverBoundServerId); + if (resolved == null) { + throw StateError('Cannot build server-bound key without a serverId'); + } + return buildGlobalKey(resolved, ratingKey); + } /// Returns the [PlexClient] for the bound server, or null when offline / /// the server is Jellyfin / not registered. Use [getServerBoundMediaClient] diff --git a/lib/mixins/settings_effect_mixin.dart b/lib/mixins/settings_effect_mixin.dart index 34d0b7cb..3da67762 100644 --- a/lib/mixins/settings_effect_mixin.dart +++ b/lib/mixins/settings_effect_mixin.dart @@ -16,7 +16,7 @@ mixin SettingsEffectMixin on State { /// 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); + final notifier = SettingsService.instance.listenable(pref); void listener() => effect(notifier.value); notifier.addListener(listener); _settingsEffectDisposers.add(() => notifier.removeListener(listener)); @@ -28,7 +28,7 @@ mixin SettingsEffectMixin on State { /// 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 svc = SettingsService.instance; final merged = Listenable.merge(prefs.map(svc.listenableOf).toList(growable: false)); void listener() { if (mounted) setState(() {}); diff --git a/lib/profiles/active_profile_provider.dart b/lib/profiles/active_profile_provider.dart index d751aaae..665567a7 100644 --- a/lib/profiles/active_profile_provider.dart +++ b/lib/profiles/active_profile_provider.dart @@ -22,7 +22,12 @@ import 'profile_registry.dart'; /// local profiles first, then live home users; if neither matches we fall /// back to the first profile in the merged list. class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifierMixin { - ActiveProfileProvider({required this._registry, required this._plexHome, required this._connections, this._storage}); + ActiveProfileProvider({ + required this._registry, + required this._plexHome, + required this._connections, + StorageService? storage, + }) : _storage = storage; final ProfileRegistry _registry; final PlexHomeService _plexHome; diff --git a/lib/profiles/plex_home_service.dart b/lib/profiles/plex_home_service.dart index 30fa634f..fda56315 100644 --- a/lib/profiles/plex_home_service.dart +++ b/lib/profiles/plex_home_service.dart @@ -23,10 +23,11 @@ class PlexHomeService { PlexHomeService({ required this._connections, required this._profileConnections, - this._storage, + StorageService? storage, Future> Function(String accountToken)? plexHomeUserFetcher, this._refreshInterval = const Duration(hours: 1), - }) : _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher; + }) : _storage = storage, + _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher; final ConnectionRegistry _connections; final ProfileConnectionRegistry _profileConnections; diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index b2ed9460..ad21c3fb 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -17,7 +17,6 @@ import '../services/download_artwork_service.dart'; import '../services/download_storage_service.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_mode_source.dart'; -import '../services/storage_service.dart'; import '../services/watch_state_resolver.dart'; import '../media/media_server_client.dart'; import '../services/sync_rule_executor.dart'; @@ -83,10 +82,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Track items currently being deleted with progress final Map _deletionProgress = {}; - // Track total episode counts for shows/seasons (for partial download detection) - // Key: globalKey (serverId:ratingKey), Value: total episode count - final Map _totalEpisodeCounts = {}; - // Persistent sync rules keyed by profile-scoped globalKey // (profileId|serverId:ratingKey). Downloads remain public/shared. final Map _syncRules = {}; @@ -112,7 +107,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } /// Test-only constructor that skips the heavy initial load (artwork dir, - /// pinned-metadata bulk fetch, episode counts). Only sync rules are loaded + /// pinned-metadata bulk fetch). Only sync rules are loaded /// from the database. Use this in tests that exercise the provider's public /// database-backed API without mocking [DownloadStorageService], /// or path_provider. @@ -129,12 +124,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } /// Inject the offline-mode source so queueing paths can short-circuit when - /// the device has no Plex connectivity. Propagates to the download manager - /// and the sync-rule executor so background paths see the same flag. + /// the device has no Plex connectivity. Sync-rule execution receives a + /// snapshot of this state when invoked, keeping this provider as the owner. void setOfflineSource(OfflineModeSource? source) { _offlineSource = source; _downloadManager.setOfflineSource(source); - _syncRuleExecutor.setOfflineSource(source); } /// Ensures persisted downloads have been loaded from disk. @@ -226,7 +220,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _downloads.remove(globalKey); _metadata.remove(globalKey); _artworkPaths.remove(globalKey); - _totalEpisodeCounts.remove(globalKey); if (meta != null) { DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true); } @@ -249,7 +242,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Map? downloads, Map? metadata, Map? artwork, - Map? episodeCounts, Set? queueing, Map? deletionProgress, Set? ownedDownloadKeys, @@ -257,7 +249,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (downloads != null) _downloads.addAll(downloads); if (metadata != null) _metadata.addAll(metadata); if (artwork != null) _artworkPaths.addAll(artwork); - if (episodeCounts != null) _totalEpisodeCounts.addAll(episodeCounts); if (queueing != null) _queueing.addAll(queueing); if (deletionProgress != null) _deletionProgress.addAll(deletionProgress); if (ownedDownloadKeys != null) { @@ -267,10 +258,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } - /// Test-only inspector for `_totalEpisodeCounts` (no public getter today). - @visibleForTesting - int? totalEpisodeCountFor(String globalKey) => _totalEpisodeCounts[globalKey]; - /// Load all persisted downloads and metadata from the database/cache Future _loadPersistedDownloads() async { try { @@ -282,7 +269,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _downloads.clear(); _artworkPaths.clear(); _metadata.clear(); - _totalEpisodeCounts.clear(); _queueing.clear(); _deletionProgress.clear(); _ownedDownloadKeys.clear(); @@ -332,9 +318,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } - // Load total episode counts from StorageService - await _loadTotalEpisodeCounts(); - // Load sync rules from database await _loadProfileScopedState(); @@ -345,7 +328,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin appLogger.i( 'Loaded ${_downloads.length} downloads, ${_metadata.length} metadata entries, ' - '${_totalEpisodeCounts.length} episode counts, and ${_syncRules.length} sync rules', + 'and ${_syncRules.length} sync rules', ); safeNotifyListeners(); } catch (e) { @@ -393,30 +376,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin return downloadedScope == null || downloadedScope.isEmpty ? null : downloadedScope; } - /// Load total episode counts from StorageService - Future _loadTotalEpisodeCounts() async { - try { - final storage = await StorageService.getInstance(); - final counts = storage.loadAllEpisodeCounts(); - _totalEpisodeCounts.addAll(counts); - - appLogger.i('Loaded ${_totalEpisodeCounts.length} episode counts from StorageService'); - } catch (e) { - appLogger.w('Failed to load episode counts', error: e); - } - } - - /// Persist total episode count to StorageService - Future _persistTotalEpisodeCount(String globalKey, int count) async { - try { - final storage = await StorageService.getInstance(); - await storage.saveTotalEpisodeCount(globalKey, count); - appLogger.d('Persisted episode count for $globalKey: $count'); - } catch (e) { - appLogger.w('Failed to persist episode count for $globalKey', error: e); - } - } - /// Load parent (show and season) metadata from a pre-loaded map (no DB I/O). /// Used during bulk initialization to avoid per-item DB queries. void _loadParentMetadataFromMap(MediaItem episode, Map allMetadata, {String? clientScopeId}) { @@ -671,9 +630,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // returns just the owned download records, so episodes.length IS the queued // count. Downloading 5 of a 50-episode show therefore reaches 100% at 5/5. // - // NOTE: the show's full episode count (metadata.leafCount / _totalEpisodeCounts) - // is intentionally not used as the denominator here. - // TODO: remove the now-unread _totalEpisodeCounts plumbing in a dedicated cleanup. final int totalEpisodes = episodes.length; if (totalEpisodes == 0) { @@ -1156,14 +1112,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _artworkPaths[globalKey] = DownloadedArtwork(thumbPath: thumbPath); } - /// Store leafCount for a show or season so aggregate progress works. - Future _storeLeafCount(String globalKey, MediaItem metadata) async { - if (metadata.leafCount != null && metadata.leafCount! > 0) { - _totalEpisodeCounts[globalKey] = metadata.leafCount!; - await _persistTotalEpisodeCount(globalKey, metadata.leafCount!); - } - } - /// Queue all episodes from a TV show for download Future _queueShowDownload( MediaItem show, @@ -1172,7 +1120,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin DownloadFilter filter = DownloadFilter.all, int? maxCount, }) async { - await _storeLeafCount(show.globalKey, show); return _expandAndQueue( container: show, client: client, @@ -1191,7 +1138,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin DownloadFilter filter = DownloadFilter.all, int? maxCount, }) async { - await _storeLeafCount(season.globalKey, season); return _expandAndQueue( container: season, client: client, @@ -1328,7 +1274,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _downloads.remove(globalKey); _metadata.remove(globalKey); _artworkPaths.remove(globalKey); - _totalEpisodeCounts.remove(globalKey); } if (removedMeta != null) { DeletionNotifier().notifyDeletedItem(item: removedMeta, isDownloadOnly: true); @@ -1383,17 +1328,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } Future _deleteOwnedContainerDownloads(String globalKey, MediaItem container) async { - final removedCount = _totalEpisodeCounts.remove(globalKey); - final storage = await StorageService.getInstance(); - await storage.removeEpisodeCount(globalKey); - appLogger.i( - 'Removed episode count for $globalKey\n' - ' - Removed count value: $removedCount\n' - ' - Metadata type: ${container.kind.id}\n' - ' - Metadata title: ${container.title}\n' - ' - Remaining stored counts: ${_totalEpisodeCounts.length}', - ); - final descendants = _ownedDescendantEntries(container).toList(); for (final entry in descendants) { await deleteDownload(entry.key); @@ -1723,6 +1657,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin metadata: Map.unmodifiable(_metadata), queueSingleDownload: (episode, client, {int mediaIndex = 0}) => _queueSingleDownload(episode, client, mediaIndex: mediaIndex, relatedContext: relatedContext), + isOffline: _offlineSource?.isOffline ?? false, force: force, ); @@ -1750,6 +1685,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin metadata: Map.unmodifiable(_metadata), queueSingleDownload: (episode, client, {int mediaIndex = 0}) => _queueSingleDownload(episode, client, mediaIndex: mediaIndex, relatedContext: relatedContext), + isOffline: _offlineSource?.isOffline ?? false, ); } diff --git a/lib/providers/hidden_libraries_provider.dart b/lib/providers/hidden_libraries_provider.dart index 2f56b204..fbde16e8 100644 --- a/lib/providers/hidden_libraries_provider.dart +++ b/lib/providers/hidden_libraries_provider.dart @@ -6,12 +6,12 @@ import '../services/storage_service.dart'; /// This ensures that when a library is hidden/unhidden in one screen, /// all other screens are automatically updated. class HiddenLibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixin { - late StorageService _storageService; + StorageService? _storageService; Set _hiddenLibraryKeys = {}; bool _isInitialized = false; Future? _initFuture; - HiddenLibrariesProvider() { + HiddenLibrariesProvider({StorageService? storageService}) : _storageService = storageService { // Start initialization eagerly to reduce race conditions _initFuture = _initialize(); } @@ -29,8 +29,8 @@ class HiddenLibrariesProvider extends ChangeNotifier with DisposableChangeNotifi /// Initialize the provider by loading hidden libraries from storage Future _initialize() async { if (_isInitialized) return; - _storageService = await StorageService.getInstance(); - _hiddenLibraryKeys = _storageService.getHiddenLibraries(); + final storage = _storageService ??= await StorageService.getInstance(); + _hiddenLibraryKeys = storage.getHiddenLibraries(); _isInitialized = true; safeNotifyListeners(); } @@ -41,7 +41,7 @@ class HiddenLibrariesProvider extends ChangeNotifier with DisposableChangeNotifi if (!_isInitialized) await _initialize(); if (!_hiddenLibraryKeys.contains(libraryKey)) { _hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..add(libraryKey); - await _storageService.saveHiddenLibraries(_hiddenLibraryKeys); + await _storageService!.saveHiddenLibraries(_hiddenLibraryKeys); safeNotifyListeners(); } } @@ -52,7 +52,7 @@ class HiddenLibrariesProvider extends ChangeNotifier with DisposableChangeNotifi if (!_isInitialized) await _initialize(); if (_hiddenLibraryKeys.contains(libraryKey)) { _hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..remove(libraryKey); - await _storageService.saveHiddenLibraries(_hiddenLibraryKeys); + await _storageService!.saveHiddenLibraries(_hiddenLibraryKeys); safeNotifyListeners(); } } @@ -63,7 +63,8 @@ class HiddenLibrariesProvider extends ChangeNotifier with DisposableChangeNotifi /// Refresh hidden libraries from storage /// Useful if storage was modified outside the provider Future refresh() async { - _hiddenLibraryKeys = _storageService.getHiddenLibraries(); + final storage = _storageService ??= await StorageService.getInstance(); + _hiddenLibraryKeys = storage.getHiddenLibraries(); safeNotifyListeners(); } } diff --git a/lib/providers/libraries_provider.dart b/lib/providers/libraries_provider.dart index 3da3394d..c4d94302 100644 --- a/lib/providers/libraries_provider.dart +++ b/lib/providers/libraries_provider.dart @@ -14,6 +14,9 @@ enum LibrariesLoadState { initial, loading, loaded, error } /// Both SideNavigationRail and LibrariesScreen consume this provider /// instead of independently fetching library data. class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixin { + LibrariesProvider({StorageService? storageService}) : _storageService = storageService; + + StorageService? _storageService; DataAggregationService? _aggregationService; List _libraries = []; LibrariesLoadState _loadState = LibrariesLoadState.initial; @@ -134,7 +137,7 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi final filteredLibraries = result.libraries.where((lib) => !ContentTypeHelper.isMusicLibrary(lib)).toList(); // Apply saved library order - final storage = await StorageService.getInstance(); + final storage = _storageService ??= await StorageService.getInstance(); final savedOrder = storage.getLibraryOrder(); final orderedLibraries = _applyLibraryOrder(filteredLibraries, savedOrder); @@ -179,7 +182,7 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi safeNotifyListeners(); // Save the new order - final storage = await StorageService.getInstance(); + final storage = _storageService ??= await StorageService.getInstance(); final libraryKeys = orderedLibraries.map((lib) => lib.globalKey).toList(); await storage.saveLibraryOrder(libraryKeys); diff --git a/lib/providers/offline_mode_provider.dart b/lib/providers/offline_mode_provider.dart index 5fc1e59c..aadc17e2 100644 --- a/lib/providers/offline_mode_provider.dart +++ b/lib/providers/offline_mode_provider.dart @@ -6,6 +6,15 @@ import 'multi_server_provider.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_mode_source.dart'; +enum OfflineModeReason { + online, + noNetworkConnection, + waitingForServerStatus, + noKnownVisibleServers, + onlyAuthErrorServers, + noServerConnection, +} + /// Tracks offline mode status based on network connectivity and server reachability. class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMixin implements OfflineModeSource { final MultiServerManager _serverManager; @@ -41,12 +50,16 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi /// Whether the app is currently in offline mode /// Offline = no network OR (we know servers are unreachable) @override - bool get isOffline { - if (!_hasNetworkConnection) return true; - if (!_hasReceivedServerStatus) return false; - if (!_hasKnownVisibleServers) return false; - if (_hasOnlyAuthErrorServers) return false; - return !_hasServerConnection; + bool get isOffline => + offlineReason == OfflineModeReason.noNetworkConnection || offlineReason == OfflineModeReason.noServerConnection; + + OfflineModeReason get offlineReason { + if (!_hasNetworkConnection) return OfflineModeReason.noNetworkConnection; + if (!_hasReceivedServerStatus) return OfflineModeReason.waitingForServerStatus; + if (!_hasKnownVisibleServers) return OfflineModeReason.noKnownVisibleServers; + if (_hasOnlyAuthErrorServers) return OfflineModeReason.onlyAuthErrorServers; + if (!_hasServerConnection) return OfflineModeReason.noServerConnection; + return OfflineModeReason.online; } /// Whether there is network connectivity (WiFi, mobile data, etc.) diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 909a89f7..2f97edeb 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -173,7 +173,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { _playQueueTotalCount = response.playQueueTotalCount ?? response.size ?? response.items!.length; _playQueueShuffled = response.playQueueShuffled; safeNotifyListeners(); - return true; + return _findLoadedIndex(targetPlayQueueItemID) != -1; } } catch (e) { // Failed to load items @@ -228,6 +228,11 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { return -1; } + MediaItem? _findLoadedItem(int playQueueItemId) { + final index = _findLoadedIndex(playQueueItemId); + return index == -1 ? null : _loadedItems[index]; + } + /// Gets the next item in the playback queue. /// Returns null if queue is exhausted or current item is not in queue. /// [loopQueue] - If true, restart from beginning when queue is exhausted @@ -277,11 +282,9 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { final last = _loadedItems.last; final nextItemID = last is PlexMediaItem ? last.playQueueItemId : null; if (nextItemID != null) { - final loaded = await _ensureItemsLoaded(nextItemID + 1); - if (loaded) { - // Try again with newly loaded items - return getNextEpisode(currentItemKey, loopQueue: loopQueue); - } + final targetPlayQueueItemID = nextItemID + 1; + final loaded = await _ensureItemsLoaded(targetPlayQueueItemID); + if (loaded) return _findLoadedItem(targetPlayQueueItemID); } } @@ -316,10 +319,9 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { final first = _loadedItems.first; final prevItemID = first is PlexMediaItem ? first.playQueueItemId : null; if (prevItemID != null && prevItemID > 0) { - final loaded = await _ensureItemsLoaded(prevItemID - 1); - if (loaded) { - return getPreviousEpisode(currentItemKey); - } + final targetPlayQueueItemID = prevItemID - 1; + final loaded = await _ensureItemsLoaded(targetPlayQueueItemID); + if (loaded) return _findLoadedItem(targetPlayQueueItemID); } } diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index e2caf267..4ebfeb73 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -33,6 +33,8 @@ import '../utils/app_logger.dart'; /// account-owner's token would silently return the *owner's* settings — /// wrong defaults for kid profiles, parental restrictions, etc. class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMixin { + UserProfileProvider({StorageService? storageService}) : _storageService = storageService; + MediaServerUserProfile? _profileSettings; bool _isLoading = false; String? _error; diff --git a/lib/providers/watch_state_overlay_provider.dart b/lib/providers/watch_state_overlay_provider.dart index 04a40452..119c105f 100644 --- a/lib/providers/watch_state_overlay_provider.dart +++ b/lib/providers/watch_state_overlay_provider.dart @@ -80,16 +80,11 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti static MediaItem applyPatch(MediaItem item, WatchStateOverlayPatch? patch) { if (patch == null) return item; - - var updated = item; - final isWatched = patch.isWatched; - if (isWatched != null) { - updated = updated.copyWith(viewCount: isWatched ? 1 : 0); - } - if (patch.hasViewOffsetMs) { - updated = updated.copyWith(viewOffsetMs: patch.viewOffsetMs); - } - return updated; + return WatchStateSnapshot( + isWatched: patch.isWatched, + hasViewOffsetMs: patch.hasViewOffsetMs, + viewOffsetMs: patch.viewOffsetMs, + ).apply(item); } void setActiveProfileId(String? profileId) { diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index ca797788..c7b1b667 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1397,7 +1397,7 @@ class _DiscoverScreenState extends State } Widget _buildContent(BuildContext context) { - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; final showHeroSection = svc.read(SettingsService.showHeroSection); if (PlatformDetector.isTV()) { @@ -1546,7 +1546,7 @@ class _DiscoverScreenState extends State final size = MediaQuery.sizeOf(context); final theme = Theme.of(context); final spotlight = _effectiveSpotlightItem; - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; final hideSpoilers = svc.read(SettingsService.hideSpoilers); final browseHubs = _tvBrowseHubs; final scale = TvLayoutConstants.scaleForSize(size); @@ -1806,7 +1806,7 @@ class _DiscoverScreenState extends State final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow; // Spoiler protection - final hideSpoilers = SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers); + final hideSpoilers = SettingsService.instance.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 55d9f9a5..9685084f 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -321,7 +321,7 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> { return SettingsBuilder( prefs: const [SettingsService.libraryDensity, SettingsService.tvFullCardLayout], builder: (context) { - final settings = SettingsService.instanceOrNull!; + final settings = SettingsService.instance; final density = settings.read(SettingsService.libraryDensity); final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout); final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index 79f2b928..ca5ef788 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -171,7 +171,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu return SettingsBuilder( prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], builder: (context) { - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; final libraryDensity = svc.read(SettingsService.libraryDensity); final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); @@ -261,7 +261,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu return SettingsBuilder( prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], builder: (context) { - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; final libraryDensity = svc.read(SettingsService.libraryDensity); final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index fe7af0ee..1202dc31 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -514,7 +514,7 @@ class _HubDetailScreenState extends State SettingsService.tvFullCardLayout, ], builder: (context) { - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; final episodePosterMode = svc.read(SettingsService.episodePosterMode); final libraryDensity = svc.read(SettingsService.libraryDensity); diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index d665d8b8..e7902950 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -149,7 +149,7 @@ class FolderTreeItem extends StatelessWidget { Widget _buildMediaRow(BuildContext context) { final indentation = depth * 24.0; - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; final episodePosterMode = svc.read(SettingsService.episodePosterMode); final hideSpoilers = svc.read(SettingsService.hideSpoilers); final showUnwatchedCount = svc.read(SettingsService.showUnwatchedCount); diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index a94d0b65..8152c443 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -96,8 +96,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState widget.library.serverId; - String _toGlobalKey(String ratingKey, {ServerId? serverId}) => - buildGlobalKey(ServerId(serverId ?? widget.library.serverId ?? ''), ratingKey); + String _toGlobalKey(String ratingKey, {required ServerId serverId}) => buildGlobalKey(serverId, ratingKey); @override String? get deletionServerId => widget.library.serverId; @@ -114,9 +113,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState{}; for (final item in loadedItems.values) { - final serverId = item.serverId ?? widget.library.serverId; + final serverId = serverIdOrNull(item.serverId ?? widget.library.serverId); if (serverId == null) return null; - keys.add(_toGlobalKey(item.id, serverId: ServerId(serverId))); + keys.add(_toGlobalKey(item.id, serverId: serverId)); } return keys; } @@ -130,9 +129,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState{}; for (final item in loadedItems.values) { - final serverId = item.serverId ?? widget.library.serverId; + final serverId = serverIdOrNull(item.serverId ?? widget.library.serverId); if (serverId == null) return null; - keys.add(_toGlobalKey(item.id, serverId: ServerId(serverId))); + keys.add(_toGlobalKey(item.id, serverId: serverId)); } return keys; } @@ -280,7 +279,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState().serverManager; - return manager.getPlexClient(ServerId(library.serverId ?? ''))!; + final serverId = serverIdOrNull(library.serverId); + if (serverId == null) throw StateError('Plex library ${library.id} is missing a serverId'); + return manager.getPlexClient(serverId)!; }, libraryKey: library.id, isShared: library.isShared, @@ -1579,7 +1580,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState on State { required LiveTvProgram program, required LiveTvChannel? channel, required String? posterThumb, - required String posterServerId, + required String? posterServerId, }) { final multiServer = context.read(); - final client = multiServer.getClientForServer(ServerId(posterServerId)); + final serverId = serverIdOrNull(posterServerId); + final client = serverId == null ? null : multiServer.getClientForServer(serverId); String? posterUrl; if (posterThumb != null && client != null) { posterUrl = MediaImageHelper.getOptimizedImageUrl( diff --git a/lib/screens/livetv/reorder_favorites_sheet.dart b/lib/screens/livetv/reorder_favorites_sheet.dart index e526eb51..d402798b 100644 --- a/lib/screens/livetv/reorder_favorites_sheet.dart +++ b/lib/screens/livetv/reorder_favorites_sheet.dart @@ -269,7 +269,8 @@ class _ReorderFavoritesSheetState extends State { }) { final colorScheme = Theme.of(context).colorScheme; final multiServer = context.read(); - final client = multiServer.getClientForServer(ServerId(channel?.serverId ?? '')); + final serverId = serverIdOrNull(channel?.serverId); + final client = serverId == null ? null : multiServer.getClientForServer(serverId); Color? tileColor; if (isMoving) { diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index a0f573dc..2ba19a39 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -1131,7 +1131,8 @@ class GuideTabState extends State with MountedSetStateMixin { Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) { final multiServer = context.read(); - final client = multiServer.getClientForServer(ServerId(channel.serverId ?? '')); + final serverId = serverIdOrNull(channel.serverId); + final client = serverId == null ? null : multiServer.getClientForServer(serverId); final isFocused = _hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 0 && _gridChannelIndex == index; @@ -1359,7 +1360,8 @@ class GuideTabState extends State with MountedSetStateMixin { void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) { final multiServer = context.read(); - final client = multiServer.getClientForServer(ServerId(channel.serverId ?? '')); + final serverId = serverIdOrNull(channel.serverId); + final client = serverId == null ? null : multiServer.getClientForServer(serverId); String? posterUrl; if (program.thumb != null && client != null) { posterUrl = MediaImageHelper.getOptimizedImageUrl( diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index b631cf04..8c3439d9 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -146,13 +146,13 @@ class WhatsOnTabState extends State with LiveTvActionsMixin LiveTvShowScheduleScreen( showTitle: entry.metadata.displayTitle, - serverId: entry.metadata.serverId ?? '', + serverId: entry.metadata.serverId!, channels: widget.channels, ), ), @@ -163,7 +163,7 @@ class WhatsOnTabState extends State with LiveTvActionsMixin with LiveTvActionsMixin _handleVerticalNavigation(index, isUp), onBack: widget.onBack, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index a112c48d..6a071dec 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1296,10 +1296,10 @@ class _MediaDetailScreenState extends State // Offline mode: try to load full metadata from cache (has clearLogo, summary, etc.) if (widget.isOffline) { - final cachedMetadata = await context.read().lookupOfflineMetadata( - ServerId(_metadata.serverId ?? ''), - _metadata.id, - ); + final serverId = serverIdOrNull(_metadata.serverId); + final cachedMetadata = serverId == null + ? null + : await context.read().lookupOfflineMetadata(serverId, _metadata.id); if (!mounted) return; setState(() { _fullMetadata = _applyLocalProgress(cachedMetadata ?? _metadata); @@ -2055,7 +2055,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 = SettingsService.instanceOrNull!.read(SettingsService.libraryDensity); + final density = SettingsService.instance.read(SettingsService.libraryDensity); final availableWidth = MediaQuery.sizeOf(context).width; return GridSizeCalculator.getCellWidth(availableWidth, context, density); } @@ -3078,7 +3078,7 @@ class _MediaDetailScreenState extends State ) { final size = MediaQuery.sizeOf(context); final detailHubs = _tvDetailHubs(metadata); - final hideSpoilers = SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers); + final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers); final detailScale = TvLayoutConstants.scaleForSize(size); final spotlightTop = (size.height * 0.08).clamp(44.0 * detailScale, 110.0 * detailScale).toDouble(); final rawRailHeight = _estimateTvDetailRailHeight(size, detailHubs); @@ -3419,7 +3419,7 @@ class _MediaDetailScreenState extends State } double _estimateTvBrowseRailHeight(Size size, List hubs) { - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; return TvBrowseRailLayout.estimateHeight( size: size, hubs: hubs, @@ -3439,7 +3439,7 @@ class _MediaDetailScreenState extends State } double _estimateTvDetailEmptyRailReserveHeight(Size size) { - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; final scale = TvBrowseRailLayout.scaleForSize(size); final availableWidth = size.width - TvBrowseRailLayout.horizontalInsetForScale(scale); if (availableWidth <= 0) return 0; @@ -3471,7 +3471,7 @@ class _MediaDetailScreenState extends State EpisodePosterMode _tvDetailEpisodePosterModeForHub(MediaHub hub) { if (_isTvDetailEpisodeHub(hub)) return EpisodePosterMode.episodeThumbnail; - return SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode); + return SettingsService.instance.read(SettingsService.episodePosterMode); } double _tvDetailWidePosterScaleForHub(MediaHub hub) { diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index dddb1769..fe97fbf4 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -191,7 +191,7 @@ class AppearanceSettingsScreen extends StatelessWidget { currentValue: LocaleSettings.currentLocale, ); if (value != null) { - await SettingsService.instanceOrNull!.write(SettingsService.appLocale, value); + await SettingsService.instance.write(SettingsService.appLocale, value); unawaited(LocaleSettings.setLocale(value)); if (context.mounted) _restartApp(context); } @@ -215,7 +215,7 @@ class AppearanceSettingsScreen extends StatelessWidget { min: 1, max: 5, divisions: 4, - onChanged: (v) => SettingsService.instanceOrNull!.write(SettingsService.libraryDensity, v.round()), + onChanged: (v) => SettingsService.instance.write(SettingsService.libraryDensity, v.round()), ), ), Text(t.settings.comfortable, style: const TextStyle(fontSize: 12, color: Colors.grey)), diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index 8c496d22..813538ed 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -37,7 +37,7 @@ class ExternalPlayerScreen extends StatelessWidget { SettingsService.customExternalPlayers, ], builder: (context) { - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; if (!svc.read(SettingsService.useExternalPlayer)) return const SizedBox.shrink(); final selected = svc.read(SettingsService.selectedExternalPlayer); final custom = svc.read(SettingsService.customExternalPlayers); @@ -72,7 +72,7 @@ class _PlayerTile extends StatelessWidget { @override Widget build(BuildContext context) { final isSelected = selectedId == player.id; - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; Widget leading; if (player.iconAsset != null) { @@ -128,7 +128,7 @@ Future _showAddCustomPlayerDialog(BuildContext context) async { final id = 'custom_${DateTime.now().millisecondsSinceEpoch}'; final newPlayer = ExternalPlayer.custom(id: id, name: result.name, value: result.value, type: result.type); - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; await svc.write(SettingsService.customExternalPlayers, [ ...svc.read(SettingsService.customExternalPlayers), newPlayer, diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index 67752c3a..7ff74631 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -25,7 +25,7 @@ class MpvConfigScreen extends StatefulWidget { } class _MpvConfigScreenState extends State with SettingsEffectMixin, ControllerDisposerMixin { - SettingsService get _settingsService => SettingsService.instanceOrNull!; + SettingsService get _settingsService => SettingsService.instance; late final TextEditingController _textController = createTextEditingController( text: _settingsService.read(SettingsService.mpvConfigText), diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index aac6d791..34ee4c83 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -226,7 +226,7 @@ class _PlaybackSettingsScreenState extends State { Widget _externalPlayerTile() => SettingsBuilder( prefs: [SettingsService.useExternalPlayer, SettingsService.selectedExternalPlayer], builder: (context) { - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; final useExt = svc.read(SettingsService.useExternalPlayer); final player = svc.read(SettingsService.selectedExternalPlayer); return SettingNavigationTile( @@ -280,7 +280,7 @@ class _PlaybackSettingsScreenState extends State { SettingsService.matchContentFrameRate, ], builder: (context) { - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; final shouldShow = (Platform.isWindows && (svc.read(SettingsService.matchRefreshRate) || svc.read(SettingsService.matchDynamicRange))) || diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index c09223c8..75e04482 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -132,7 +132,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun return KeyEventResult.ignored; } - settings.SettingsService get _settingsService => settings.SettingsService.instanceOrNull!; + settings.SettingsService get _settingsService => settings.SettingsService.instance; @override Widget build(BuildContext context) { diff --git a/lib/screens/settings/tracker_account_settings_body.dart b/lib/screens/settings/tracker_account_settings_body.dart index ae4bf487..93ba25ee 100644 --- a/lib/screens/settings/tracker_account_settings_body.dart +++ b/lib/screens/settings/tracker_account_settings_body.dart @@ -69,7 +69,7 @@ class TrackerAccountSettingsBody extends StatelessWidget { SettingsBuilder( prefs: [SettingsService.trackerFilterModePref(service), SettingsService.trackerFilterIdsPref(service)], builder: (context) { - final settings = SettingsService.instanceOrNull!; + final settings = SettingsService.instance; return ListTile( leading: const AppIcon(Symbols.filter_list_rounded, fill: 1), title: Text(t.trackers.libraryFilter.title), diff --git a/lib/screens/settings/tracker_library_filter_screen.dart b/lib/screens/settings/tracker_library_filter_screen.dart index cca407c0..572c513c 100644 --- a/lib/screens/settings/tracker_library_filter_screen.dart +++ b/lib/screens/settings/tracker_library_filter_screen.dart @@ -47,7 +47,7 @@ class TrackerLibraryFilterScreen extends StatelessWidget { return SettingsBuilder( prefs: [modePref, idsPref], builder: (context) { - final settings = SettingsService.instanceOrNull!; + final settings = SettingsService.instance; final mode = settings.read(modePref); final selectedIds = settings.read(idsPref).toSet(); final theme = Theme.of(context); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 85f90346..74f4a7bd 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -467,6 +467,15 @@ class SettingsService extends BaseSharedPreferencesService { /// Synchronous access to the singleton, or null if not yet initialized. static SettingsService? get instanceOrNull => _cachedInstance; + /// Synchronous access to the bootstrapped singleton. + static SettingsService get instance { + final instance = _cachedInstance; + if (instance == null) { + throw StateError('SettingsService has not been initialized. Call SettingsService.getInstance() first.'); + } + return instance; + } + /// Drop the cached singleton so the next [getInstance] call rebuilds against /// the current SharedPreferences state. Test-only — pair with /// [BaseSharedPreferencesService.resetForTesting]. diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 1e27285c..52111636 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -374,41 +374,6 @@ class StorageService extends BaseSharedPreferencesService { await _clearKeysWithPrefix(_prefixProfileLastUsed); } - // Episode Count Persistence (for partial download detection) - - static const String _prefixEpisodeCount = 'episode_count_'; - - /// Save the total episode count for a show/season - Future saveTotalEpisodeCount(String globalKey, int count) async { - await prefs.setInt('$_prefixEpisodeCount$globalKey', count); - } - - /// Get the total episode count for a show/season - int? getTotalEpisodeCount(String globalKey) { - return prefs.getInt('$_prefixEpisodeCount$globalKey'); - } - - /// Load all persisted episode counts - Map loadAllEpisodeCounts() { - final counts = {}; - final keys = prefs.keys.where((k) => k.startsWith(_prefixEpisodeCount)); - - for (final key in keys) { - final globalKey = key.replaceFirst(_prefixEpisodeCount, ''); - final count = prefs.getInt(key); - if (count != null) { - counts[globalKey] = count; - } - } - - return counts; - } - - /// Remove the episode count for a specific show/season - Future removeEpisodeCount(String globalKey) async { - await prefs.remove('$_prefixEpisodeCount$globalKey'); - } - // Private helper methods /// Helper to read and decode JSON `List` from preferences diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index ed30d587..07d30b64 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -12,7 +12,6 @@ import '../utils/episode_collection.dart'; import '../utils/global_key_utils.dart'; import 'download_manager_service.dart'; import 'multi_server_manager.dart'; -import 'offline_mode_source.dart'; import 'playlist_items_loader.dart'; /// Sync-rule filter values stored in `SyncRules.downloadFilter`. @@ -45,18 +44,10 @@ class SyncRuleExecutor { static const Duration _cooldownWifi = Duration(minutes: 30); static const Duration _cooldownCellular = Duration(hours: 3); - OfflineModeSource? _offlineSource; - SyncRuleExecutor({required this._database}); bool get isExecuting => _isExecuting; - /// Inject the offline-mode source so we can skip running rules when the - /// device has no Plex connectivity (every `getChildren` call would fail). - void setOfflineSource(OfflineModeSource? source) { - _offlineSource = source; - } - /// Execute every enabled sync rule. /// /// The adaptive cooldown (30 min on WiFi/Ethernet, 3 h on cellular) only @@ -74,6 +65,7 @@ class SyncRuleExecutor { required Map downloads, required Map metadata, required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, + required bool isOffline, bool force = false, }) async { if (_isExecuting) { @@ -81,7 +73,7 @@ class SyncRuleExecutor { return []; } - if (_offlineSource?.isOffline ?? false) { + if (isOffline) { appLogger.d('Skipping sync rules — offline'); return []; } @@ -148,13 +140,14 @@ class SyncRuleExecutor { required Map downloads, required Map metadata, required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, + required bool isOffline, }) async { if (_isExecuting) { appLogger.d('Sync rule execution already in progress, skipping single-rule run for $globalKey'); return null; } - if (_offlineSource?.isOffline ?? false) { + if (isOffline) { appLogger.d('Skipping single sync rule $globalKey — offline'); return null; } diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart index c1fe9da7..d7af7b45 100644 --- a/lib/services/trakt/trakt_sync_service.dart +++ b/lib/services/trakt/trakt_sync_service.dart @@ -49,13 +49,15 @@ class TraktSyncService { /// Plex resolves via `?includeGuids=1`, Jellyfin reads inline `ProviderIds`. final Map _resolvers = {}; - /// Fallback buffer for items that failed to persist to the on-disk queue - /// (e.g. SharedPreferences write threw). Retried on next `flushQueue`. - /// Bounded to keep memory pressure finite; oldest items drop first. + /// Fallback buffers for items that failed to persist to the on-disk queue + /// (e.g. SharedPreferences write threw). Keyed by profile so a profile switch + /// cannot replay one user's failed writes through another user's Trakt client. + /// Bounded per profile to keep memory pressure finite; oldest items drop first. static const int _maxInMemoryFallback = 100; - final Queue _inMemoryFallback = Queue(); + final Map> _inMemoryFallbackByUser = {}; bool _isFlushing = false; + bool _flushRequested = false; Future initialize({required MultiServerManager serverManager}) async { if (_isInitialized) return; @@ -83,9 +85,7 @@ class TraktSyncService { _client = session != null ? TraktClient(session, onSessionInvalidated: onSessionInvalidated) : null; _activeUserUuid = userUuid; _resolvers.clear(); - if (_client != null) { - unawaited(flushQueue()); - } + if (_client != null) unawaited(flushQueue()); } Future dispose() async { @@ -260,9 +260,10 @@ class TraktSyncService { } Future _trySendOrQueue(TraktSyncQueueItem item, TraktScrobbleRequest body) async { + final userUuid = _activeUserUuid; final client = _client; if (client == null) { - await _persistOrBuffer(item); + await _persistOrBuffer(userUuid, item); return; } try { @@ -270,27 +271,28 @@ class TraktSyncService { appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} → ok'); } catch (e) { appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} failed, queuing', error: e); - await _persistOrBuffer(item); + await _persistOrBuffer(userUuid, item); } } /// Persist an item to the on-disk queue; fall back to a bounded in-memory /// buffer if the disk write throws (e.g. disk full, SAF permission revoked). /// Retried at the start of the next `flushQueue` run. - Future _persistOrBuffer(TraktSyncQueueItem item) async { + Future _persistOrBuffer(String userUuid, TraktSyncQueueItem item) async { try { - await _queue.add(_activeUserUuid, item); + await _queue.add(userUuid, item); } catch (e, st) { appLogger.e( 'Trakt sync: queue persist failed for ${item.op.name} ${item.ratingKey}, buffering in memory', error: e, stackTrace: st, ); - if (_inMemoryFallback.length >= _maxInMemoryFallback) { - final dropped = _inMemoryFallback.removeFirst(); + final fallback = _inMemoryFallbackByUser.putIfAbsent(userUuid, Queue.new); + if (fallback.length >= _maxInMemoryFallback) { + final dropped = fallback.removeFirst(); appLogger.w('Trakt sync: in-memory fallback full, dropping ${dropped.op.name} ${dropped.ratingKey}'); } - _inMemoryFallback.addLast(item); + fallback.addLast(item); } } @@ -304,14 +306,18 @@ class TraktSyncService { /// Drain the persisted queue. Called on init, on app foreground, and when /// `OfflineModeProvider.isOffline` flips false. Future flushQueue() async { - if (_isFlushing) return; + if (_isFlushing) { + _flushRequested = true; + return; + } final client = _client; if (client == null) return; + final userUuid = _activeUserUuid; _isFlushing = true; try { - await _recoverInMemoryFallback(); + await _recoverInMemoryFallback(userUuid); - await _queue.drainWith(_activeUserUuid, (item) async { + await _queue.drainWith(userUuid, (item) async { if (!_isLibraryAllowed(item.libraryGlobalKey)) { appLogger.d('Trakt sync: queued library filtered out for ${item.ratingKey}'); return null; @@ -333,18 +339,24 @@ class TraktSyncService { }); } finally { _isFlushing = false; + if (_flushRequested) { + _flushRequested = false; + if (_client != null) unawaited(flushQueue()); + } } } /// Try to move items buffered in memory (because prior disk writes failed) /// back onto the persistent queue. Best-effort; items that still can't be /// persisted stay in the buffer for the next flush. - Future _recoverInMemoryFallback() async { - if (_inMemoryFallback.isEmpty) return; - final snapshot = List.from(_inMemoryFallback); - _inMemoryFallback.clear(); + Future _recoverInMemoryFallback(String userUuid) async { + final fallback = _inMemoryFallbackByUser[userUuid]; + if (fallback == null || fallback.isEmpty) return; + final snapshot = List.from(fallback); + fallback.clear(); + if (fallback.isEmpty) _inMemoryFallbackByUser.remove(userUuid); for (final item in snapshot) { - await _persistOrBuffer(item); + await _persistOrBuffer(userUuid, item); } } diff --git a/lib/utils/deletion_notifier.dart b/lib/utils/deletion_notifier.dart index d9f50c84..61cf4dd1 100644 --- a/lib/utils/deletion_notifier.dart +++ b/lib/utils/deletion_notifier.dart @@ -73,10 +73,15 @@ class DeletionNotifier extends BaseNotifier { } void notifyDeletedItem({required MediaItem item, bool isDownloadOnly = false}) { + final serverId = serverIdOrNull(item.serverId); + if (serverId == null) { + appLogger.w('DeletionNotifier: missing serverId for ${item.id}, skipping deletion event'); + return; + } notify( DeletionEvent( itemId: item.id, - serverId: ServerId(item.serverId ?? ''), + serverId: serverId, parentChain: item.parentChain, mediaType: item.kind.id, leafCount: item.leafCount ?? 1, diff --git a/lib/utils/global_key_utils.dart b/lib/utils/global_key_utils.dart index 4b513343..d1a4a691 100644 --- a/lib/utils/global_key_utils.dart +++ b/lib/utils/global_key_utils.dart @@ -18,7 +18,7 @@ String buildProfileScopedGlobalKey(String profileId, ServerId serverId, String r /// Uses [indexOf] so ratingKeys containing colons are handled correctly. ({ServerId serverId, String ratingKey})? parseGlobalKey(String globalKey) { final idx = globalKey.indexOf(':'); - if (idx < 0) return null; + if (idx <= 0) return null; return (serverId: ServerId(globalKey.substring(0, idx)), ratingKey: globalKey.substring(idx + 1)); } diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index aa398112..73877f3d 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -123,9 +123,9 @@ Future navigateToVideoPlayer( // Plex-only client. The player branches on the returned type internally. final manager = context.read().serverManager; final offlineWatchService = context.read(); - final serverId = metadata.serverId ?? ''; - final mediaClient = serverId.isNotEmpty && (!isOffline || manager.isClientOnline(ServerId(serverId))) - ? manager.getClient(ServerId(serverId)) + final serverId = serverIdOrNull(metadata.serverId); + final mediaClient = serverId != null && (!isOffline || manager.isClientOnline(serverId)) + ? manager.getClient(serverId) : null; int mediaIndex = selectedMediaIndex ?? 0; @@ -296,7 +296,7 @@ Future navigateToWatchTogetherPlayback( VoidCallback? onBeforeNavigate, }) async { final multiServer = context.read(); - final client = multiServer.getClientForServer(ServerId(serverId)); + final client = multiServer.getClientForServer(serverId); if (client == null) { throw const WatchTogetherPlaybackNavigationException('Watch Together server is unavailable'); diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index 70414258..99e43236 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -97,10 +97,15 @@ class WatchStateNotifier extends BaseNotifier { /// Helper to emit a watched/unwatched event from a [MediaItem]. void notifyWatched({required MediaItem item, bool isNowWatched = true, String? cacheServerId}) { + final serverId = serverIdOrNull(item.serverId); + if (serverId == null) { + appLogger.w('WatchStateNotifier: missing serverId for ${item.id}, skipping watched event'); + return; + } notify( WatchStateEvent( itemId: item.id, - serverId: ServerId(item.serverId ?? ''), + serverId: serverId, cacheServerId: cacheServerId, changeType: isNowWatched ? WatchStateChangeType.watched : WatchStateChangeType.unwatched, parentChain: item.parentChain, @@ -120,12 +125,17 @@ class WatchStateNotifier extends BaseNotifier { required int duration, double watchedThreshold = 0.9, }) { + final serverId = serverIdOrNull(item.serverId); + if (serverId == null) { + appLogger.w('WatchStateNotifier: missing serverId for ${item.id}, skipping progress event'); + return; + } final isNowWatched = duration > 0 && (viewOffset / duration) >= watchedThreshold; notify( WatchStateEvent( itemId: item.id, - serverId: ServerId(item.serverId ?? ''), + serverId: serverId, changeType: WatchStateChangeType.progressUpdate, parentChain: item.parentChain, mediaType: item.kind.id, @@ -138,10 +148,15 @@ class WatchStateNotifier extends BaseNotifier { /// Helper to emit a Continue Watching removal event. void notifyRemovedFromContinueWatching({required MediaItem item}) { + final serverId = serverIdOrNull(item.serverId); + if (serverId == null) { + appLogger.w('WatchStateNotifier: missing serverId for ${item.id}, skipping continue-watching removal event'); + return; + } notify( WatchStateEvent( itemId: item.id, - serverId: ServerId(item.serverId ?? ''), + serverId: serverId, changeType: WatchStateChangeType.removedFromContinueWatching, parentChain: item.parentChain, mediaType: item.kind.id, diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 10df29d7..a66a0bce 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -226,7 +226,7 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin 0 && mi.viewOffsetMs! < mi.durationMs!; diff --git a/lib/widgets/setting_tile.dart b/lib/widgets/setting_tile.dart index a28aaf42..1339aa7a 100644 --- a/lib/widgets/setting_tile.dart +++ b/lib/widgets/setting_tile.dart @@ -14,7 +14,7 @@ import 'settings_section.dart'; /// surround every settings row. class _TileBase { - static SettingsService get _svc => SettingsService.instanceOrNull!; + static SettingsService get _svc => SettingsService.instance; } /// SwitchListTile bound to a [Pref]. diff --git a/lib/widgets/settings_builder.dart b/lib/widgets/settings_builder.dart index a7c319ba..2a962e23 100644 --- a/lib/widgets/settings_builder.dart +++ b/lib/widgets/settings_builder.dart @@ -7,8 +7,8 @@ import '../services/settings_service.dart'; /// 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); + T settingsRead(Pref pref) => SettingsService.instance.read(pref); + Future settingsWrite(Pref pref, T value) => SettingsService.instance.write(pref, value); } /// Rebuild [builder] when any of [prefs] changes. Use when a widget's output @@ -23,7 +23,7 @@ class SettingsBuilder extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; return ListenableBuilder( listenable: Listenable.merge(prefs.map(svc.listenableOf).toList(growable: false)), builder: (context, _) => builder(context), @@ -44,7 +44,7 @@ class SettingValueBuilder extends StatelessWidget { @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: SettingsService.instanceOrNull!.listenable(pref), + valueListenable: SettingsService.instance.listenable(pref), builder: builder, child: child, ); diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index b86bcd1e..a39bc4d4 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -587,11 +587,11 @@ class SideNavigationRailState extends State with MountedSetS return ListenableBuilder( listenable: Listenable.merge([ FullscreenStateManager(), - SettingsService.instanceOrNull!.listenable(SettingsService.groupLibrariesByServer), + SettingsService.instance.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 groupByServerSetting = SettingsService.instance.read(SettingsService.groupLibrariesByServer); final showServerHeaders = serverIds.length > 1 && groupByServerSetting; _collapsedServerGroupKeys.retainAll( _buildServerGroupStateKeys(visibleLibraries, hiddenLibraries, showServerHeaders: showServerHeaders), diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index b368cb68..dcadf44a 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -839,7 +839,7 @@ class TvBrowseRailState extends State { ], builder: (context) => LayoutBuilder( builder: (context, constraints) { - final svc = SettingsService.instanceOrNull!; + final svc = SettingsService.instance; final hasFocus = _focusNode.hasFocus; final theme = Theme.of(context); final scale = _scale(context); diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart index 9f7c02d0..3ebf7d53 100644 --- a/lib/widgets/video_controls/models/track_controls_state.dart +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -51,7 +51,7 @@ class TrackControlsState { final VoidCallback? onCancelAutoHide; final VoidCallback? onStartAutoHide; final void Function(String propertyName, int offset)? onSyncOffsetChanged; - final String serverId; + final String? serverId; final ShaderService? shaderService; final VoidCallback? onShaderChanged; final bool isAmbientLightingEnabled; @@ -110,7 +110,7 @@ class TrackControlsState { this.onCancelAutoHide, this.onStartAutoHide, this.onSyncOffsetChanged, - this.serverId = '', + this.serverId, this.shaderService, this.onShaderChanged, this.isAmbientLightingEnabled = false, @@ -133,7 +133,8 @@ class TrackControlsState { /// External subtitle search needs both a searchable media item and a server /// that can proxy the OpenSubtitles request. - bool get canSearchSubtitles => ratingKey.isNotEmpty && serverId.isNotEmpty && subtitleSearchSupported; + bool get canSearchSubtitles => + ratingKey.isNotEmpty && serverId != null && serverId!.isNotEmpty && subtitleSearchSupported; /// Whether the track sheet should expose subtitle controls at all. This is /// the single source of truth shared by the toolbar icon and the sheet layout. diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index 9820bb24..87d54887 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -136,7 +136,7 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { // to SettingsService and the parent re-reads via `_audioSyncOffset` / // `_subtitleSyncOffset` getters. Callback kept for sheet API compat. onSyncOffsetChanged: null, - serverId: widget.metadata.serverId ?? '', + serverId: widget.metadata.serverId, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, isAmbientLightingEnabled: widget.isAmbientLightingEnabled, diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index f482b904..88deda8f 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -503,7 +503,7 @@ List _buildSubtitleSearchFooter(BuildContext context, TrackControlsState OverlaySheetController.of(context).push( builder: (_) => SubtitleSearchSheet( ratingKey: state.ratingKey, - serverId: state.serverId, + serverId: state.serverId!, mediaTitle: state.mediaTitle, onSubtitleDownloaded: state.onSubtitleDownloaded, ), diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 6a5acadb..7197eb68 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -101,7 +101,7 @@ class _SettingsToggleItem extends StatelessWidget { @override Widget build(BuildContext context) { - final settings = SettingsService.instanceOrNull!; + final settings = SettingsService.instance; return ValueListenableBuilder( valueListenable: settings.listenable(pref), builder: (context, value, _) { @@ -288,7 +288,7 @@ class _VideoSettingsSheetState extends State { initialOffset: initialOffset, sliderFocusNode: sliderFocusNode, onOffsetChanged: (offset) async { - final settings = SettingsService.instanceOrNull!; + final settings = SettingsService.instance; if (isSubtitle) { await settings.write(SettingsService.subtitleSyncOffset, offset); } else { @@ -685,7 +685,7 @@ class _VideoSettingsSheetState extends State { onTap: () async { await widget.player.setRate(speed); // Save as default playback speed - await SettingsService.instanceOrNull!.write(SettingsService.defaultPlaybackSpeed, speed); + await SettingsService.instance.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 24c9fe84..f5fb2efc 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -331,7 +331,7 @@ class _PlexVideoControlsState extends State // 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!; + SettingsService get _settings => SettingsService.instance; int get _seekTimeSmall => _settings.read(SettingsService.seekTimeSmall); int get _rewindOnResume => _settings.read(SettingsService.rewindOnResume); int get _audioSyncOffset => _settings.read(SettingsService.audioSyncOffset); diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index e23b74e3..fadad422 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -89,7 +89,7 @@ class TrackChapterControls extends StatelessWidget { VoidCallback? get onCancelAutoHide => trackControlsState.onCancelAutoHide; VoidCallback? get onStartAutoHide => trackControlsState.onStartAutoHide; void Function(String propertyName, int offset)? get onSyncOffsetChanged => trackControlsState.onSyncOffsetChanged; - String get serverId => trackControlsState.serverId; + String? get serverId => trackControlsState.serverId; ShaderService? get shaderService => trackControlsState.shaderService; VoidCallback? get onShaderChanged => trackControlsState.onShaderChanged; bool get isAmbientLightingEnabled => trackControlsState.isAmbientLightingEnabled; diff --git a/lib/widgets/video_controls/widgets/volume_control.dart b/lib/widgets/video_controls/widgets/volume_control.dart index 698b8487..20bda087 100644 --- a/lib/widgets/video_controls/widgets/volume_control.dart +++ b/lib/widgets/video_controls/widgets/volume_control.dart @@ -53,7 +53,7 @@ class _VolumeControlState extends State { /// Volume step size for keyboard adjustment. static const double _volumeStep = 5.0; - SettingsService get _settings => SettingsService.instanceOrNull!; + SettingsService get _settings => SettingsService.instance; void _enterAdjustMode() { setState(() { diff --git a/test/mixins/server_bound_media_mixin_test.dart b/test/mixins/server_bound_media_mixin_test.dart index 507a3db6..b4d052c1 100644 --- a/test/mixins/server_bound_media_mixin_test.dart +++ b/test/mixins/server_bound_media_mixin_test.dart @@ -118,13 +118,12 @@ void main() { expect(state.toServerBoundGlobalKey('rk-1', serverId: ServerId('srv-B')), 'srv-B:rk-1'); }); - testWidgets('toServerBoundGlobalKey falls back to empty serverId when metadata has none', (tester) async { + testWidgets('toServerBoundGlobalKey rejects metadata without a serverId', (tester) async { late _ProbeState state; await tester.pumpWidget(_Probe(metadata: _meta(), offline: false, onState: (s, _) => state = s)); await tester.pump(); - // Empty server prefix is the documented fallback for server-less metadata. - expect(state.toServerBoundGlobalKey('rk-1'), ':rk-1'); + expect(() => state.toServerBoundGlobalKey('rk-1'), throwsStateError); }); testWidgets('getServerBoundPlexClient returns null in offline mode regardless of providers', (tester) async { diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index b46f884d..b2e350cd 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -821,7 +821,7 @@ void main() { }); group('DownloadProvider — cancelDownload map symmetry', () { - test('cancelDownload removes download, metadata, artwork, and episode count', () async { + test('cancelDownload removes download, metadata, and artwork', () async { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); @@ -838,7 +838,6 @@ void main() { ), }, artwork: {key: const DownloadedArtwork(thumbPath: '/art/42.jpg')}, - episodeCounts: {key: 7}, ); await p.cancelDownload(key); @@ -846,7 +845,6 @@ void main() { expect(p.getProgress(key), isNull); expect(p.getMetadata(key), isNull); expect(p.getArtworkPaths(key), isNull, reason: 'artwork path must not orphan after cancel'); - expect(p.totalEpisodeCountFor(key), isNull, reason: 'episode count must not orphan after cancel'); p.dispose(); }); diff --git a/test/providers/playback_state_provider_test.dart b/test/providers/playback_state_provider_test.dart index 2dbd4f4b..b9139839 100644 --- a/test/providers/playback_state_provider_test.dart +++ b/test/providers/playback_state_provider_test.dart @@ -170,6 +170,22 @@ void main() { p.dispose(); }); + test('getNextEpisode does not retry recursively when loaded window misses target', () async { + final p = PlaybackStateProvider(); + addTearDown(p.dispose); + final items = [_item('a', 1001), _item('b', 1002)]; + await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1002, totalCount: 3, items: items), null); + + var fetchCount = 0; + p.setPlayQueueWindowFetcher((playQueueId, {center, window = 50}) async { + fetchCount++; + return _queue(playQueueID: playQueueId, selectedItemID: 1002, totalCount: 3, items: items); + }); + + expect(await p.getNextEpisode('b'), isNull); + expect(fetchCount, 1); + }); + test('getNextEpisode with no queue returns null (sequential mode)', () async { final p = PlaybackStateProvider(); final next = await p.getNextEpisode('any-key'); diff --git a/test/services/episode_navigation_service_test.dart b/test/services/episode_navigation_service_test.dart index 2857bd6f..76510a1b 100644 --- a/test/services/episode_navigation_service_test.dart +++ b/test/services/episode_navigation_service_test.dart @@ -33,12 +33,12 @@ import 'package:provider/provider.dart'; MediaItem _meta(String id, {String? title}) => MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id'); -MediaItem _jfEpisode(String id, {required String seriesId, ServerId serverId = const ServerId('srv-jf')}) => MediaItem( +MediaItem _jfEpisode(String id, {required String seriesId, ServerId? serverId}) => MediaItem( id: id, backend: MediaBackend.jellyfin, kind: MediaKind.episode, title: 'Episode $id', - serverId: serverId, + serverId: serverId ?? ServerId('srv-jf'), grandparentId: seriesId, ); diff --git a/test/services/jellyfin_sequential_launcher_test.dart b/test/services/jellyfin_sequential_launcher_test.dart index fb40dcbe..5ec8ed37 100644 --- a/test/services/jellyfin_sequential_launcher_test.dart +++ b/test/services/jellyfin_sequential_launcher_test.dart @@ -82,22 +82,37 @@ class _RecordingJellyfinClient implements JellyfinClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -MediaItem _ep(String id, {ServerId? serverId = const ServerId('srv-jf')}) => MediaItem( +MediaItem _ep(String id, {ServerId? serverId}) => MediaItem( id: id, backend: MediaBackend.jellyfin, kind: MediaKind.episode, title: 'Episode $id', - serverId: serverId, + serverId: serverId ?? ServerId('srv-jf'), ); -MediaItem _movie(String id, {ServerId? serverId = const ServerId('srv-jf')}) => - MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.movie, title: 'Movie $id', serverId: serverId); +MediaItem _movie(String id, {ServerId? serverId}) => MediaItem( + id: id, + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + title: 'Movie $id', + serverId: serverId ?? ServerId('srv-jf'), +); -MediaItem _clip(String id, {ServerId? serverId = const ServerId('srv-jf')}) => - MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.clip, title: 'Video $id', serverId: serverId); +MediaItem _clip(String id, {ServerId? serverId}) => MediaItem( + id: id, + backend: MediaBackend.jellyfin, + kind: MediaKind.clip, + title: 'Video $id', + serverId: serverId ?? ServerId('srv-jf'), +); -MediaItem _track(String id, {ServerId? serverId = const ServerId('srv-jf')}) => - MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.track, title: 'Track $id', serverId: serverId); +MediaItem _track(String id, {ServerId? serverId}) => MediaItem( + id: id, + backend: MediaBackend.jellyfin, + kind: MediaKind.track, + title: 'Track $id', + serverId: serverId ?? ServerId('srv-jf'), +); void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index 90f31108..71360360 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -264,14 +264,15 @@ class _DelayedStartClient extends _FakePlexClient { } } -MediaItem _meta({String ratingKey = '42', ServerId? serverId = const ServerId('srv'), String? type = 'movie'}) => - MediaItem( - id: ratingKey, - backend: MediaBackend.plex, - kind: MediaKind.fromString(type), - title: 'Test Item', - serverId: serverId, - ); +const Object _defaultServerId = Object(); + +MediaItem _meta({String ratingKey = '42', Object? serverId = _defaultServerId, String? type = 'movie'}) => MediaItem( + id: ratingKey, + backend: MediaBackend.plex, + kind: MediaKind.fromString(type), + title: 'Test Item', + serverId: identical(serverId, _defaultServerId) ? ServerId('srv') : serverId as ServerId?, +); void main() { setUp(resetSharedPreferencesForTest); diff --git a/test/services/storage_service_test.dart b/test/services/storage_service_test.dart index 99265f5f..1fcb4670 100644 --- a/test/services/storage_service_test.dart +++ b/test/services/storage_service_test.dart @@ -356,41 +356,6 @@ void main() { }); }); - // ============================================================ - // Episode count persistence (prefix-based) - // ============================================================ - - group('Episode counts', () { - test('per-key round-trip', () async { - final s = await StorageService.getInstance(); - await s.saveTotalEpisodeCount('srv:show-1', 12); - await s.saveTotalEpisodeCount('srv:show-2', 24); - expect(s.getTotalEpisodeCount('srv:show-1'), 12); - expect(s.getTotalEpisodeCount('srv:show-2'), 24); - expect(s.getTotalEpisodeCount('srv:missing'), isNull); - }); - - test('loadAllEpisodeCounts returns every persisted entry', () async { - final s = await StorageService.getInstance(); - await s.saveTotalEpisodeCount('srv:s1', 1); - await s.saveTotalEpisodeCount('srv:s2', 2); - // Unrelated keys must not bleed in. - await s.prefs.setString('plex_token', 'tok'); - - final counts = s.loadAllEpisodeCounts(); - expect(counts, {'srv:s1': 1, 'srv:s2': 2}); - }); - - test('removeEpisodeCount deletes only the targeted entry', () async { - final s = await StorageService.getInstance(); - await s.saveTotalEpisodeCount('srv:s1', 1); - await s.saveTotalEpisodeCount('srv:s2', 2); - await s.removeEpisodeCount('srv:s1'); - expect(s.getTotalEpisodeCount('srv:s1'), isNull); - expect(s.getTotalEpisodeCount('srv:s2'), 2); - }); - }); - // ============================================================ // clearCredentials // ============================================================ @@ -407,10 +372,9 @@ void main() { await s.prefs.setString('server_order', json.encode(['a'])); await s.saveServerEndpoint(ServerId('a'), 'http://foo.test'); - // Library prefs and unrelated counters: write WITHOUT an active profile id + // Library prefs: write WITHOUT an active profile id // so they land on the legacy unscoped key. await s.saveLibraryOrder(['lib-1']); - await s.saveTotalEpisodeCount('srv:s1', 7); // Now seed current_user_uuid — clearCredentials should remove this. await s.prefs.setString('current_user_uuid', 'u-x'); @@ -433,7 +397,6 @@ void main() { // Library prefs and unrelated state untouched (no scope active, so // the scoped read falls through to the same legacy key it was written to). expect(s.getLibraryOrder(), ['lib-1']); - expect(s.getTotalEpisodeCount('srv:s1'), 7); }); }); diff --git a/test/services/sync_rule_executor_test.dart b/test/services/sync_rule_executor_test.dart index d1dab46c..0be55edf 100644 --- a/test/services/sync_rule_executor_test.dart +++ b/test/services/sync_rule_executor_test.dart @@ -110,6 +110,7 @@ void main() { queued.add((item: item, client: client)); return true; }, + isOffline: false, force: true, ); @@ -180,6 +181,7 @@ void main() { queued.add(item); return true; }, + isOffline: false, force: true, ); @@ -224,6 +226,7 @@ void main() { downloads: const {}, metadata: const {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async => true, + isOffline: false, force: true, ); @@ -287,6 +290,7 @@ void main() { queued.add(item); return true; }, + isOffline: false, force: true, ); @@ -336,6 +340,7 @@ void main() { queued.add(item); return true; }, + isOffline: false, force: true, ); diff --git a/test/services/trackers/tracker_coordinator_manual_test.dart b/test/services/trackers/tracker_coordinator_manual_test.dart index 8bfba5e8..aef4e26e 100644 --- a/test/services/trackers/tracker_coordinator_manual_test.dart +++ b/test/services/trackers/tracker_coordinator_manual_test.dart @@ -36,11 +36,11 @@ class _FakeMediaServerClient implements MediaServerClient { final double watchedThreshold; _FakeMediaServerClient({ - this.serverId = const ServerId('server-1'), + ServerId? serverId, required this.externalIdsByItem, required this.descendantsByParent, this.watchedThreshold = 0.9, - }); + }) : serverId = serverId ?? ServerId('server-1'); @override MediaBackend get backend => MediaBackend.plex; diff --git a/test/utils/global_key_utils_test.dart b/test/utils/global_key_utils_test.dart index 7997d354..aca5761a 100644 --- a/test/utils/global_key_utils_test.dart +++ b/test/utils/global_key_utils_test.dart @@ -8,10 +8,12 @@ void main() { expect(buildGlobalKey(ServerId('server'), '123'), 'server:123'); }); - test('passes through empty components', () { - expect(buildGlobalKey(ServerId(''), '123'), ':123'); + test('allows empty ratingKey', () { expect(buildGlobalKey(ServerId('server'), ''), 'server:'); - expect(buildGlobalKey(ServerId(''), ''), ':'); + }); + + test('rejects empty serverId', () { + expect(() => ServerId(''), throwsArgumentError); }); }); @@ -35,11 +37,8 @@ void main() { expect(result.ratingKey, 'path:with:colons'); }); - test('allows empty serverId', () { - final result = parseGlobalKey(':42'); - expect(result, isNotNull); - expect(result!.serverId, ''); - expect(result.ratingKey, '42'); + test('rejects empty serverId', () { + expect(parseGlobalKey(':42'), isNull); }); test('allows empty ratingKey', () { @@ -51,7 +50,7 @@ void main() { }); test('round-trip build → parse returns original components', () { - for (final pair in const [('s1', '42'), ('serverXYZ', '/library/metadata/123'), ('', 'abc'), ('s', '')]) { + for (final pair in const [('s1', '42'), ('serverXYZ', '/library/metadata/123'), ('s', '')]) { final built = buildGlobalKey(ServerId(pair.$1), pair.$2); final parsed = parseGlobalKey(built); expect(parsed, isNotNull); diff --git a/test/utils/media_hub_ordering_test.dart b/test/utils/media_hub_ordering_test.dart index 3fb8ba7f..3c931bcb 100644 --- a/test/utils/media_hub_ordering_test.dart +++ b/test/utils/media_hub_ordering_test.dart @@ -7,27 +7,37 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/utils/media_hub_ordering.dart'; -MediaLibrary _library(String id, {ServerId serverId = const ServerId('server')}) { +const Object _defaultServerId = Object(); + +MediaLibrary _library(String id, {ServerId? serverId}) { return MediaLibrary( id: id, backend: MediaBackend.plex, title: 'Library $id', kind: MediaKind.movie, - serverId: serverId, + serverId: serverId ?? ServerId('server'), ); } -MediaItem _item(String id, {String? libraryId, ServerId? serverId = const ServerId('server')}) { - return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, libraryId: libraryId, serverId: serverId); +MediaItem _item(String id, {String? libraryId, Object? serverId = _defaultServerId}) { + return MediaItem( + id: id, + backend: MediaBackend.plex, + kind: MediaKind.movie, + libraryId: libraryId, + serverId: identical(serverId, _defaultServerId) ? ServerId('server') : serverId as ServerId?, + ); } -MediaHub _hub( - String id, { - String? libraryId, - ServerId? serverId = const ServerId('server'), - List items = const [], -}) { - return MediaHub(id: id, title: id, type: 'movie', libraryId: libraryId, serverId: serverId, items: items); +MediaHub _hub(String id, {String? libraryId, Object? serverId = _defaultServerId, List items = const []}) { + return MediaHub( + id: id, + title: id, + type: 'movie', + libraryId: libraryId, + serverId: identical(serverId, _defaultServerId) ? ServerId('server') : serverId as ServerId?, + items: items, + ); } void main() {