fix: harden server-scoped state

This commit is contained in:
edde746
2026-06-04 13:47:06 +02:00
parent 79c06b82d3
commit 145881318e
65 changed files with 358 additions and 360 deletions
+20 -8
View File
@@ -206,9 +206,11 @@ Future<void> _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<void> _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<String?> _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<MainApp> createState() => _MainAppState();
@@ -678,6 +683,8 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
// Expose AppDatabase + ConnectionRegistry so screens (Settings, Setup)
// can manage stored Jellyfin/Plex connections without re-creating
// the registry per-call site.
Provider<SettingsService>.value(value: widget.settings),
Provider<StorageService>.value(value: widget.storage),
Provider<AppDatabase>.value(value: _appDatabase),
Provider<ConnectionRegistry>(create: (_) => ConnectionRegistry(_appDatabase)),
Provider<ProfileRegistry>(create: (_) => ProfileRegistry(_appDatabase)),
@@ -690,6 +697,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
final service = PlexHomeService(
connections: context.read<ConnectionRegistry>(),
profileConnections: context.read<ProfileConnectionRegistry>(),
storage: context.read<StorageService>(),
);
unawaited(service.start());
return service;
@@ -702,6 +710,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
registry: context.read<ProfileRegistry>(),
plexHome: context.read<PlexHomeService>(),
connections: context.read<ConnectionRegistry>(),
storage: context.read<StorageService>(),
);
unawaited(provider.initialize());
return provider;
@@ -829,7 +838,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
),
ChangeNotifierProxyProvider2<OfflineWatchSyncService, DownloadProvider, OfflineWatchProvider>(
create: (context) => OfflineWatchProvider(
syncService: _offlineWatchSyncService,
syncService: context.read<OfflineWatchSyncService>(),
downloadProvider: context.read<DownloadProvider>(),
),
update: (_, syncService, downloadProvider, previous) {
@@ -837,9 +846,9 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
},
),
ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
create: (_) => UserProfileProvider(),
create: (context) => UserProfileProvider(storageService: context.read<StorageService>()),
update: (context, activeProfile, connections, previous) {
final provider = previous ?? UserProfileProvider();
final provider = previous ?? UserProfileProvider(storageService: context.read<StorageService>());
provider.attach(
connections: connections,
activeProfile: activeProfile,
@@ -854,10 +863,13 @@ class _MainAppState extends State<MainApp> 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<StorageService>()),
lazy: true,
),
ChangeNotifierProvider(
create: (context) {
final provider = LibrariesProvider();
final provider = LibrariesProvider(storageService: context.read<StorageService>());
// 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
+14 -2
View File
@@ -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);
+7 -2
View File
@@ -15,8 +15,13 @@ mixin ServerBoundMediaMixin<T extends StatefulWidget> on State<T> {
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]
+2 -2
View File
@@ -16,7 +16,7 @@ mixin SettingsEffectMixin<T extends StatefulWidget> on State<T> {
/// Subscribe to changes of [pref] and run [effect]. Auto-disposed in [dispose].
void bindEffect<V>(Pref<V> 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<T extends StatefulWidget> on State<T> {
/// 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<Pref<Object?>> 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(() {});
+6 -1
View File
@@ -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;
+3 -2
View File
@@ -23,10 +23,11 @@ class PlexHomeService {
PlexHomeService({
required this._connections,
required this._profileConnections,
this._storage,
StorageService? storage,
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
this._refreshInterval = const Duration(hours: 1),
}) : _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher;
}) : _storage = storage,
_fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher;
final ConnectionRegistry _connections;
final ProfileConnectionRegistry _profileConnections;
+6 -70
View File
@@ -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<String, DeletionProgress> _deletionProgress = {};
// Track total episode counts for shows/seasons (for partial download detection)
// Key: globalKey (serverId:ratingKey), Value: total episode count
final Map<String, int> _totalEpisodeCounts = {};
// Persistent sync rules keyed by profile-scoped globalKey
// (profileId|serverId:ratingKey). Downloads remain public/shared.
final Map<String, SyncRuleItem> _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<String, DownloadProgress>? downloads,
Map<String, MediaItem>? metadata,
Map<String, DownloadedArtwork>? artwork,
Map<String, int>? episodeCounts,
Set<String>? queueing,
Map<String, DeletionProgress>? deletionProgress,
Set<String>? 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<void> _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<void> _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<void> _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<String, MediaItem> 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<void> _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<int> _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<void> _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,
);
}
+8 -7
View File
@@ -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<String> _hiddenLibraryKeys = {};
bool _isInitialized = false;
Future<void>? _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<void> _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<void> refresh() async {
_hiddenLibraryKeys = _storageService.getHiddenLibraries();
final storage = _storageService ??= await StorageService.getInstance();
_hiddenLibraryKeys = storage.getHiddenLibraries();
safeNotifyListeners();
}
}
+5 -2
View File
@@ -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<MediaLibrary> _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);
+19 -6
View File
@@ -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.)
+12 -10
View File
@@ -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);
}
}
+2
View File
@@ -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;
@@ -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) {
+3 -3
View File
@@ -1397,7 +1397,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
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<DiscoverScreen>
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<DiscoverScreen>
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
+1 -1
View File
@@ -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);
@@ -171,7 +171,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, 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<T extends StatefulWidget> on State<T>, 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);
+1 -1
View File
@@ -514,7 +514,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
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);
+1 -1
View File
@@ -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);
@@ -96,8 +96,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
@override
String? get itemServerId => 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<MediaItem, LibraryBrows
final keys = <String>{};
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<MediaItem, LibraryBrows
final keys = <String>{};
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<MediaItem, LibraryBrows
// only constructed when the library's backend is Plex — the bang is safe.
plexClientProvider: () {
final manager = context.read<MultiServerProvider>().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<MediaItem, LibraryBrows
/// Builds either a sliver list or sliver grid based on the view mode
Widget _buildItemsSliver(BuildContext context) {
final svc = SettingsService.instanceOrNull!;
final svc = SettingsService.instance;
final viewMode = svc.read(SettingsService.viewMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
@@ -109,7 +109,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final settings = SettingsService.instanceOrNull!;
final settings = SettingsService.instance;
final viewMode = settings.read(SettingsService.viewMode);
final density = settings.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout);
@@ -111,7 +111,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final settings = SettingsService.instanceOrNull!;
final settings = SettingsService.instance;
final viewMode = settings.read(SettingsService.viewMode);
final density = settings.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout);
@@ -300,7 +300,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
final spotlight = _effectiveSpotlightItem;
final size = MediaQuery.sizeOf(context);
final theme = Theme.of(context);
final svc = SettingsService.instanceOrNull!;
final svc = SettingsService.instance;
final client = context.tryGetMediaClientForServer(serverIdOrNull(spotlight?.serverId ?? widget.library.serverId));
final scale = TvLayoutConstants.scaleForSize(size);
final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf(
@@ -49,10 +49,11 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
required LiveTvProgram program,
required LiveTvChannel? channel,
required String? posterThumb,
required String posterServerId,
required String? posterServerId,
}) {
final multiServer = context.read<MultiServerProvider>();
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(
@@ -269,7 +269,8 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
}) {
final colorScheme = Theme.of(context).colorScheme;
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(ServerId(channel?.serverId ?? ''));
final serverId = serverIdOrNull(channel?.serverId);
final client = serverId == null ? null : multiServer.getClientForServer(serverId);
Color? tileColor;
if (isMoving) {
+4 -2
View File
@@ -1131,7 +1131,8 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) {
final multiServer = context.read<MultiServerProvider>();
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<GuideTab> with MountedSetStateMixin {
void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) {
final multiServer = context.read<MultiServerProvider>();
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(
+4 -4
View File
@@ -146,13 +146,13 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
if (entry.program.isCurrentlyAiring && channel != null) {
// Live → play directly
tuneChannel(channel);
} else if (entry.metadata.isShow) {
} else if (entry.metadata.isShow && serverIdOrNull(entry.metadata.serverId) != null) {
// Show with upcoming episodes → show full schedule
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LiveTvShowScheduleScreen(
showTitle: entry.metadata.displayTitle,
serverId: entry.metadata.serverId ?? '',
serverId: entry.metadata.serverId!,
channels: widget.channels,
),
),
@@ -163,7 +163,7 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
program: entry.program,
channel: channel,
posterThumb: entry.metadata.grandparentThumbPath ?? entry.metadata.thumbPath,
posterServerId: entry.metadata.serverId ?? '',
posterServerId: entry.metadata.serverId,
);
}
}
@@ -192,7 +192,7 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
program: entry.program,
channel: findChannelForProgram(entry.program),
posterThumb: entry.metadata.grandparentThumbPath ?? entry.metadata.thumbPath,
posterServerId: entry.metadata.serverId ?? '',
posterServerId: entry.metadata.serverId,
),
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp),
onBack: widget.onBack,
+9 -9
View File
@@ -1296,10 +1296,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// Offline mode: try to load full metadata from cache (has clearLogo, summary, etc.)
if (widget.isOffline) {
final cachedMetadata = await context.read<DownloadProvider>().lookupOfflineMetadata(
ServerId(_metadata.serverId ?? ''),
_metadata.id,
);
final serverId = serverIdOrNull(_metadata.serverId);
final cachedMetadata = serverId == null
? null
: await context.read<DownloadProvider>().lookupOfflineMetadata(serverId, _metadata.id);
if (!mounted) return;
setState(() {
_fullMetadata = _applyLocalProgress(cachedMetadata ?? _metadata);
@@ -2055,7 +2055,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// 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<MediaDetailScreen>
) {
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<MediaDetailScreen>
}
double _estimateTvBrowseRailHeight(Size size, List<MediaHub> hubs) {
final svc = SettingsService.instanceOrNull!;
final svc = SettingsService.instance;
return TvBrowseRailLayout.estimateHeight(
size: size,
hubs: hubs,
@@ -3439,7 +3439,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
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<MediaDetailScreen>
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) {
@@ -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)),
@@ -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<void> _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,
+1 -1
View File
@@ -25,7 +25,7 @@ class MpvConfigScreen extends StatefulWidget {
}
class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMixin, ControllerDisposerMixin {
SettingsService get _settingsService => SettingsService.instanceOrNull!;
SettingsService get _settingsService => SettingsService.instance;
late final TextEditingController _textController = createTextEditingController(
text: _settingsService.read(SettingsService.mpvConfigText),
@@ -226,7 +226,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
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<PlaybackSettingsScreen> {
SettingsService.matchContentFrameRate,
],
builder: (context) {
final svc = SettingsService.instanceOrNull!;
final svc = SettingsService.instance;
final shouldShow =
(Platform.isWindows &&
(svc.read(SettingsService.matchRefreshRate) || svc.read(SettingsService.matchDynamicRange))) ||
+1 -1
View File
@@ -132,7 +132,7 @@ class _SettingsScreenState extends State<SettingsScreen> 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) {
@@ -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),
@@ -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);
+9
View File
@@ -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].
-35
View File
@@ -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<void> 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<String, int> loadAllEpisodeCounts() {
final counts = <String, int>{};
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<void> removeEpisodeCount(String globalKey) async {
await prefs.remove('$_prefixEpisodeCount$globalKey');
}
// Private helper methods
/// Helper to read and decode JSON `List<String>` from preferences
+4 -11
View File
@@ -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<String, DownloadProgress> downloads,
required Map<String, MediaItem> metadata,
required Future<bool> 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<String, DownloadProgress> downloads,
required Map<String, MediaItem> metadata,
required Future<bool> 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;
}
+34 -22
View File
@@ -49,13 +49,15 @@ class TraktSyncService {
/// Plex resolves via `?includeGuids=1`, Jellyfin reads inline `ProviderIds`.
final Map<String, TrackerIdResolver> _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<TraktSyncQueueItem> _inMemoryFallback = Queue<TraktSyncQueueItem>();
final Map<String, Queue<TraktSyncQueueItem>> _inMemoryFallbackByUser = {};
bool _isFlushing = false;
bool _flushRequested = false;
Future<void> 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<void> dispose() async {
@@ -260,9 +260,10 @@ class TraktSyncService {
}
Future<void> _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<void> _persistOrBuffer(TraktSyncQueueItem item) async {
Future<void> _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<TraktSyncQueueItem>.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<void> 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<void> _recoverInMemoryFallback() async {
if (_inMemoryFallback.isEmpty) return;
final snapshot = List<TraktSyncQueueItem>.from(_inMemoryFallback);
_inMemoryFallback.clear();
Future<void> _recoverInMemoryFallback(String userUuid) async {
final fallback = _inMemoryFallbackByUser[userUuid];
if (fallback == null || fallback.isEmpty) return;
final snapshot = List<TraktSyncQueueItem>.from(fallback);
fallback.clear();
if (fallback.isEmpty) _inMemoryFallbackByUser.remove(userUuid);
for (final item in snapshot) {
await _persistOrBuffer(item);
await _persistOrBuffer(userUuid, item);
}
}
+6 -1
View File
@@ -73,10 +73,15 @@ class DeletionNotifier extends BaseNotifier<DeletionEvent> {
}
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,
+1 -1
View File
@@ -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));
}
+4 -4
View File
@@ -123,9 +123,9 @@ Future<bool?> navigateToVideoPlayer(
// Plex-only client. The player branches on the returned type internally.
final manager = context.read<MultiServerProvider>().serverManager;
final offlineWatchService = context.read<OfflineWatchSyncService>();
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<void> navigateToWatchTogetherPlayback(
VoidCallback? onBeforeNavigate,
}) async {
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(ServerId(serverId));
final client = multiServer.getClientForServer(serverId);
if (client == null) {
throw const WatchTogetherPlaybackNavigationException('Watch Together server is unavailable');
+18 -3
View File
@@ -97,10 +97,15 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
/// 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<WatchStateEvent> {
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<WatchStateEvent> {
/// 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,
+10 -10
View File
@@ -226,7 +226,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
} else if (widget.forceGridMode) {
viewMode = ViewMode.grid;
} else {
viewMode = SettingsService.instanceOrNull!.read(SettingsService.viewMode);
viewMode = SettingsService.instance.read(SettingsService.viewMode);
}
final semanticLabel = _buildSemanticLabel(item);
@@ -242,7 +242,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
onLongPress: showContextMenuFromTap,
onSecondaryTapDown: storeTapPosition,
onSecondaryTap: showContextMenuFromTap,
density: SettingsService.instanceOrNull!.read(SettingsService.libraryDensity),
density: SettingsService.instance.read(SettingsService.libraryDensity),
isOffline: widget.isOffline,
localPosterPath: localPosterPath,
showServerName: widget.showServerName,
@@ -449,7 +449,7 @@ class _MediaCardList extends StatelessWidget {
bool _usesWideAspectRatio() {
if (item is! MediaItem) return false;
final EpisodePosterMode mode =
episodePosterModeOverride ?? SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode);
episodePosterModeOverride ?? SettingsService.instance.read(SettingsService.episodePosterMode);
return (item as MediaItem).usesWideAspectRatio(mode);
}
@@ -536,7 +536,7 @@ class _MediaCardList extends StatelessWidget {
final mi = item as MediaItem;
if (mi.parentIndex != null && mi.index != null) {
final showEp = SettingsService.instanceOrNull!.read(SettingsService.showEpisodeNumberOnCards);
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
return showEp ? 'S${mi.parentIndex} E${mi.index}' : 'S${mi.parentIndex}';
}
@@ -571,7 +571,7 @@ class _MediaCardList extends StatelessWidget {
fontSize: _subtitleFontSize,
);
final episodeTitle = mi.displaySubtitle ?? mi.displayTitle;
final showEp = SettingsService.instanceOrNull!.read(SettingsService.showEpisodeNumberOnCards);
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
final episodeNum = (showEp && mi.index != null) ? ' E${mi.index}' : '';
return Row(
children: [
@@ -678,7 +678,7 @@ class _MediaCardList extends StatelessWidget {
const SizedBox(height: 4),
],
if (!(item is MediaItem &&
SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers) &&
SettingsService.instance.read(SettingsService.hideSpoilers) &&
(item as MediaItem).shouldHideSpoiler) &&
_summary() != null) ...[
Text(
@@ -761,8 +761,8 @@ Widget _buildPosterImage(
);
} else if (item is MediaItem) {
final EpisodePosterMode episodePosterMode =
episodePosterModeOverride ?? SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode);
final hideSpoilers = SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers);
episodePosterModeOverride ?? SettingsService.instance.read(SettingsService.episodePosterMode);
final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers);
final shouldBlur =
hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail;
final primaryPosterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext);
@@ -863,7 +863,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 = SettingsService.instanceOrNull!.read(SettingsService.showEpisodeNumberOnCards);
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
final episodeSuffix = (showEp && mi.index != null) ? ' E${mi.index}' : '';
if (mi.parentId != null) {
return Row(
@@ -908,7 +908,7 @@ class _MediaCardHelpers {
/// Builds watch progress overlay (checkmark for watched, progress bar for in-progress)
static Widget buildWatchProgress(BuildContext context, MediaItem mi) {
final showUnwatchedCount = SettingsService.instanceOrNull!.read(SettingsService.showUnwatchedCount);
final showUnwatchedCount = SettingsService.instance.read(SettingsService.showUnwatchedCount);
final hasActiveProgress =
mi.viewOffsetMs != null && mi.durationMs != null && mi.viewOffsetMs! > 0 && mi.viewOffsetMs! < mi.durationMs!;
+1 -1
View File
@@ -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<bool>].
+4 -4
View File
@@ -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<T>(Pref<T> pref) => SettingsService.instanceOrNull!.read(pref);
Future<void> settingsWrite<T>(Pref<T> pref, T value) => SettingsService.instanceOrNull!.write(pref, value);
T settingsRead<T>(Pref<T> pref) => SettingsService.instance.read(pref);
Future<void> settingsWrite<T>(Pref<T> 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<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<T>(
valueListenable: SettingsService.instanceOrNull!.listenable(pref),
valueListenable: SettingsService.instance.listenable(pref),
builder: builder,
child: child,
);
+2 -2
View File
@@ -587,11 +587,11 @@ class SideNavigationRailState extends State<SideNavigationRail> 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),
+1 -1
View File
@@ -839,7 +839,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
],
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);
@@ -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.
@@ -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,
@@ -503,7 +503,7 @@ List<Widget> _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,
),
@@ -101,7 +101,7 @@ class _SettingsToggleItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = SettingsService.instanceOrNull!;
final settings = SettingsService.instance;
return ValueListenableBuilder<bool>(
valueListenable: settings.listenable(pref),
builder: (context, value, _) {
@@ -288,7 +288,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
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<VideoSettingsSheet> {
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
}
@@ -331,7 +331,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
// 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);
@@ -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;
@@ -53,7 +53,7 @@ class _VolumeControlState extends State<VolumeControl> {
/// 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(() {
@@ -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 {
+1 -3
View File
@@ -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();
});
@@ -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');
@@ -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,
);
@@ -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();
@@ -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);
+1 -38
View File
@@ -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);
});
});
@@ -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,
);
@@ -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;
+8 -9
View File
@@ -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);
+21 -11
View File
@@ -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<MediaItem> 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<MediaItem> 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() {