diff --git a/lib/main.dart b/lib/main.dart index b753113e..98206e90 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -496,6 +496,9 @@ class _MainAppState extends State with WidgetsBindingObserver { // Trakt sync service (subscribes to WatchStateNotifier, requires serverManager // to resolve PlexClients for GUID lookups). TraktSyncService.instance.initialize(serverManager: _serverManager); + // Tracker singletons init once per app; per-profile hydration happens in + // the profile-scoped provider subtree's create callbacks. + unawaited(TrackerCoordinator.instance.initialize()); _appLifecycleListener = AppLifecycleListener( onExitRequested: () async { @@ -772,18 +775,6 @@ class _MainAppState extends State with WidgetsBindingObserver { return provider; }, ), - ChangeNotifierProxyProvider2( - create: (_) => WatchStateStore(), - update: (_, activeProfile, multiServer, previous) { - final provider = previous ?? WatchStateStore(); - provider.setActiveProfileId(activeProfile.activeId); - provider.setActiveClientScopesByServer({ - for (final serverId in multiServer.serverManager.serverIds) - serverId: multiServer.serverManager.getClient(ServerId(serverId))?.cacheServerId, - }); - return provider; - }, - ), ChangeNotifierProxyProvider( create: (context) { final offlineModeProvider = context.read(); @@ -856,41 +847,104 @@ class _MainAppState extends State with WidgetsBindingObserver { }, ), ChangeNotifierProvider(create: (context) => ThemeProvider()), - // Tracker accounts — depend on UserProfileProvider for per-profile - // session scoping. Hydrated and rebound by `_TrackerProfileBootstrap`. - ChangeNotifierProvider(create: (context) => TraktAccountProvider()), - ChangeNotifierProvider(create: (context) => TrackersProvider()), - ChangeNotifierProvider( - create: (context) => HiddenLibrariesProvider(storageService: context.read()), - lazy: true, - ), - ChangeNotifierProvider( - create: (context) { - final provider = LibrariesProvider(storageService: context.read()); - // Reload libraries when a new server comes online. Servers bind in - // waves on sign-in / profile switch and slow ones reconnect after - // the initial load; without this they stay missing from the sidebar - // until a profile re-switch or restart. - context.read().onOnlineServersChanged = provider.syncToOnlineServers; - return provider; - }, - ), - ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), - ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), - ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()), + // Shader presets are app-global — deliberately outside the + // profile-scoped subtree below. ChangeNotifierProvider(create: (context) => ShaderProvider()), ], - child: Consumer( - builder: (context, themeProvider, child) { - return TranslationProvider( - child: Builder( - builder: (context) { - final trakt = context.read(); - final trackers = context.read(); - return _TrackerProfileBootstrap( - onProfileChanged: [trakt.onActiveProfileChanged, trackers.onActiveProfileChanged], - onFirstMount: TrackerCoordinator.instance.initialize, - child: Listener( + // Profile boundary: everything below is profile-scoped BY TREE + // POSITION. Switching the active profile changes the KeyedSubtree key, + // which disposes and recreates the inner providers and the whole app + // shell — including navigation, so routes showing the previous + // profile's content cannot survive into the next. A provider belongs in + // the inner list when its state is per-profile; app-global state + // (theme, shader presets, downloads, server manager) stays above. + // Shared singletons that span profiles (DownloadProvider's sync rules, + // OfflineWatchSyncService) keep their explicit setActiveProfileId + // wiring instead. + child: Consumer( + builder: (context, activeProfile, _) { + final activeId = activeProfile.activeId; + return KeyedSubtree( + key: ValueKey(activeId), + child: MultiProvider( + providers: [ + ChangeNotifierProxyProvider( + create: (_) => WatchStateStore(), + update: (_, multiServer, previous) { + final provider = previous ?? WatchStateStore(); + provider.setActiveProfileId(activeId); + provider.setActiveClientScopesByServer({ + for (final serverId in multiServer.serverManager.serverIds) + serverId: multiServer.serverManager.getClient(ServerId(serverId))?.cacheServerId, + }); + return provider; + }, + ), + // Tracker accounts hydrate for the active profile on create — + // the subtree remount on profile switch is the rebind. + ChangeNotifierProvider( + create: (context) { + final provider = TraktAccountProvider(); + unawaited( + provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) { + appLogger.w('Trakt profile hydrate failed', error: e, stackTrace: s); + }), + ); + return provider; + }, + ), + ChangeNotifierProvider( + create: (context) { + final provider = TrackersProvider(); + unawaited( + provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) { + appLogger.w('Trackers profile hydrate failed', error: e, stackTrace: s); + }), + ); + return provider; + }, + ), + ChangeNotifierProvider( + create: (context) => HiddenLibrariesProvider(storageService: context.read()), + lazy: true, + ), + ChangeNotifierProvider( + create: (context) { + final provider = LibrariesProvider(storageService: context.read()); + // Reload libraries when a new server comes online. Servers + // bind in waves on sign-in / profile switch and slow ones + // reconnect after the initial load; without this they stay + // missing from the sidebar until a re-switch or restart. + context.read().onOnlineServersChanged = provider.syncToOnlineServers; + return provider; + }, + ), + ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), + ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), + ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()), + ], + child: const _AppShell(), + ), + ); + }, + ), + ); + } +} + +/// The app shell below the profile boundary: theme consumer, translations, +/// global input handling, and the MaterialApp. +class _AppShell extends StatelessWidget { + const _AppShell(); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, themeProvider, child) { + return TranslationProvider( + child: Builder( + builder: (context) { + return Listener( onPointerDown: (event) { if ((event.buttons & kBackMouseButton) != 0) { rootNavigatorKey.currentState?.maybePop(); @@ -930,14 +984,12 @@ class _MainAppState extends State with WidgetsBindingObserver { ), ), ), - ), - ); - }, - ), - ); - }, - ), - ); + ); + }, + ), + ); + }, + ); } } @@ -990,69 +1042,6 @@ class _AppleTvScale extends StatelessWidget { } } -/// Hydrates Trakt and MAL/AniList/Simkl providers with the active profile's -/// sessions and rebinds their services whenever the user switches profiles. -/// -/// Lives high in the widget tree (above MaterialApp) so the listener survives -/// route changes. [onFirstMount] runs exactly once after the first -/// `didChangeDependencies`. -class _TrackerProfileBootstrap extends StatefulWidget { - final Widget child; - final List Function(String? profileId)> onProfileChanged; - final VoidCallback? onFirstMount; - - const _TrackerProfileBootstrap({required this.child, required this.onProfileChanged, this.onFirstMount}); - - @override - State<_TrackerProfileBootstrap> createState() => _TrackerProfileBootstrapState(); -} - -class _TrackerProfileBootstrapState extends State<_TrackerProfileBootstrap> { - ActiveProfileProvider? _provider; - String? _lastId; - bool _initialized = false; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final provider = context.read(); - - if (!identical(_provider, provider)) { - _provider?.removeListener(_onProfileChanged); - _provider = provider; - _provider!.addListener(_onProfileChanged); - } - - if (!_initialized) { - _initialized = true; - widget.onFirstMount?.call(); - _onProfileChanged(); - } - } - - void _onProfileChanged() { - final id = _provider?.activeId; - if (id == _lastId) return; - _lastId = id; - for (final fn in widget.onProfileChanged) { - unawaited( - fn(id).catchError((Object e, StackTrace s) { - appLogger.w('Tracker profile bootstrap failed', error: e, stackTrace: s); - }), - ); - } - } - - @override - void dispose() { - _provider?.removeListener(_onProfileChanged); - super.dispose(); - } - - @override - Widget build(BuildContext context) => widget.child; -} - class OrientationAwareSetup extends StatefulWidget { const OrientationAwareSetup({super.key}); diff --git a/test/services/api_cache_watch_state_test.dart b/test/services/api_cache_watch_state_test.dart index 83895fbe..81368ded 100644 --- a/test/services/api_cache_watch_state_test.dart +++ b/test/services/api_cache_watch_state_test.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; @@ -179,7 +178,7 @@ void main() { final userData = await readBack('user-a'); expect(userData.containsKey('UnplayedItemCount'), isFalse); - expect(jsonEncode(userData), isNot(contains('9'))); + expect(userData.values, isNot(contains(9))); }); test('malformed cached rows are skipped without throwing', () async {