fix: harden server-scoped state
This commit is contained in:
+20
-8
@@ -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
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(() {});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))) ||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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!;
|
||||
|
||||
@@ -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>].
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user