380 lines
16 KiB
Dart
380 lines
16 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connection/connection_registry.dart';
|
|
import '../focus/key_event_utils.dart';
|
|
import '../media/ids.dart';
|
|
import '../media/media_server_client.dart';
|
|
import '../profiles/active_profile_provider.dart';
|
|
import '../profiles/plex_home_service.dart';
|
|
import '../profiles/profile_connection_registry.dart';
|
|
import '../providers/catalog_sources_provider.dart';
|
|
import '../providers/companion_remote_provider.dart';
|
|
import '../providers/discover_provider.dart';
|
|
import '../providers/explore_provider.dart';
|
|
import '../providers/hidden_libraries_provider.dart';
|
|
import '../providers/libraries_provider.dart';
|
|
import '../providers/multi_server_provider.dart';
|
|
import '../providers/playback_state_provider.dart';
|
|
import '../providers/trakt_account_provider.dart';
|
|
import '../providers/seerr_account_provider.dart';
|
|
import '../providers/trackers_provider.dart';
|
|
import '../providers/watch_state_store.dart';
|
|
import '../database/app_database.dart';
|
|
import '../screens/main_screen.dart';
|
|
import '../services/api_cache.dart';
|
|
import '../services/catalog/catalog_library_matcher.dart';
|
|
import '../services/music/music_playback_service.dart';
|
|
import '../services/music/music_playback_service_impl.dart';
|
|
import '../services/offline_watch_sync_service.dart';
|
|
import '../services/storage_service.dart';
|
|
import '../services/system_shelf_service.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../watch_together/providers/watch_together_provider.dart';
|
|
import '../widgets/music/mini_player.dart';
|
|
import 'profile_navigation_scope.dart';
|
|
|
|
CatalogSourcesProvider _createCatalogSourcesProvider(BuildContext context) {
|
|
return CatalogSourcesProvider(
|
|
plexSessionSupplier: () => resolvePlexDiscoverSession(
|
|
activeProfile: context.read<ActiveProfileProvider>(),
|
|
connections: context.read<ConnectionRegistry>(),
|
|
profileConnections: context.read<ProfileConnectionRegistry>(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Root route for an active profile session.
|
|
///
|
|
/// The root app navigator owns setup/auth/profile-picking. This route owns the
|
|
/// profile-scoped provider tree and a nested navigator for all content routes.
|
|
/// Changing the active profile changes the keyed boundary below, disposing the
|
|
/// old nested navigator, MainScreen, tab state, and profile-scoped providers.
|
|
///
|
|
/// Keep profile-owned routes, dialogs, sheets, and virtual keyboards on the
|
|
/// nearest navigator from this subtree. Keep setup/auth/PIN/profile-picker flows
|
|
/// on the root navigator so they survive this subtree being replaced.
|
|
class ProfileSessionScreen extends StatefulWidget {
|
|
const ProfileSessionScreen({super.key, this.isOfflineMode = false, this.initialPromptHandled = false})
|
|
: profileShellBuilder = null,
|
|
trackerHttpClientFactory = null;
|
|
|
|
@visibleForTesting
|
|
const ProfileSessionScreen.forTesting({
|
|
super.key,
|
|
this.isOfflineMode = false,
|
|
this.initialPromptHandled = false,
|
|
required this.profileShellBuilder,
|
|
required http.Client Function() httpClientFactory,
|
|
}) : trackerHttpClientFactory = httpClientFactory;
|
|
|
|
final bool isOfflineMode;
|
|
final bool initialPromptHandled;
|
|
final WidgetBuilder? profileShellBuilder;
|
|
final http.Client Function()? trackerHttpClientFactory;
|
|
|
|
@override
|
|
State<ProfileSessionScreen> createState() => _ProfileSessionScreenState();
|
|
}
|
|
|
|
class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
|
|
// Profile changes remount the inner session, but the root route survives.
|
|
// Treat the initial launch/profile prompt as handled after the first session
|
|
// frame so switching profiles from the root picker does not immediately open
|
|
// another required-selection picker underneath it. Flipped via a post-frame
|
|
// callback rather than during build to avoid mutating state mid-build.
|
|
bool _hasBuiltSession = false;
|
|
|
|
bool _seenFirstActiveId = false;
|
|
String? _lastSessionActiveId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _hasBuiltSession = true;
|
|
});
|
|
}
|
|
|
|
/// The keyed remount below recreates every session-scoped provider on a
|
|
/// profile switch, but [ApiCache] is app-global and its Plex rows are
|
|
/// keyed by server only — one home user's cached responses would serve
|
|
/// the next user's session. Clear the volatile rows at the seam itself;
|
|
/// doing it from inside MainScreen can't work, the remount unmounts it
|
|
/// before any settle-await completes.
|
|
void _onSessionProfileChanged(String? activeId) {
|
|
final shelf = SystemShelfService();
|
|
if (!_seenFirstActiveId) {
|
|
_seenFirstActiveId = true;
|
|
_lastSessionActiveId = activeId;
|
|
if (activeId != null) shelf.beginProfileSession(activeId);
|
|
return;
|
|
}
|
|
final oldOwner = _lastSessionActiveId;
|
|
if (oldOwner == activeId) return;
|
|
if (oldOwner != null) {
|
|
// endProfileSession invalidates synchronously and queues its clear before
|
|
// the new owner is admitted below.
|
|
unawaited(shelf.endProfileSession(oldOwner));
|
|
}
|
|
_lastSessionActiveId = activeId;
|
|
if (activeId != null) shelf.beginProfileSession(activeId);
|
|
unawaited(ApiCache.clearRegisteredVolatile());
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Selector<ActiveProfileProvider, String?>(
|
|
selector: (_, activeProfile) => activeProfile.activeId,
|
|
builder: (context, activeId, _) {
|
|
_onSessionProfileChanged(activeId);
|
|
final initialPromptHandled = widget.initialPromptHandled || _hasBuiltSession;
|
|
return KeyedSubtree(
|
|
key: ValueKey<String?>('profile-session:$activeId'),
|
|
child: MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProxyProvider<MultiServerProvider, WatchStateStore>(
|
|
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;
|
|
},
|
|
),
|
|
ChangeNotifierProvider(
|
|
create: (context) {
|
|
final provider = TraktAccountProvider(httpClientFactory: widget.trackerHttpClientFactory);
|
|
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(httpClientFactory: widget.trackerHttpClientFactory);
|
|
unawaited(
|
|
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
|
|
appLogger.w('Trackers profile hydrate failed', error: e, stackTrace: s);
|
|
}),
|
|
);
|
|
return provider;
|
|
},
|
|
),
|
|
ChangeNotifierProvider(
|
|
create: (context) {
|
|
final provider = SeerrAccountProvider();
|
|
provider.bindPlexTokenSupplier(
|
|
buildSeerrPlexTokenSupplier(
|
|
activeProfile: context.read<ActiveProfileProvider>(),
|
|
connections: context.read<ConnectionRegistry>(),
|
|
profileConnections: context.read<ProfileConnectionRegistry>(),
|
|
),
|
|
);
|
|
unawaited(
|
|
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
|
|
appLogger.w('Seerr profile hydrate failed', error: e, stackTrace: s);
|
|
}),
|
|
);
|
|
return provider;
|
|
},
|
|
),
|
|
ChangeNotifierProxyProvider4<
|
|
TraktAccountProvider,
|
|
TrackersProvider,
|
|
SeerrAccountProvider,
|
|
ActiveProfileProvider,
|
|
CatalogSourcesProvider
|
|
>(
|
|
create: (context) {
|
|
final provider = _createCatalogSourcesProvider(context);
|
|
unawaited(
|
|
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
|
|
appLogger.w('Catalog sources profile hydrate failed', error: e, stackTrace: s);
|
|
}),
|
|
);
|
|
return provider;
|
|
},
|
|
update: (context, trakt, trackers, seerr, activeProfile, previous) {
|
|
final provider = previous ?? _createCatalogSourcesProvider(context);
|
|
provider.update(trakt, trackers, seerr);
|
|
unawaited(provider.onProfileBindingStateChanged(activeProfile.isBinding));
|
|
return provider;
|
|
},
|
|
),
|
|
ChangeNotifierProvider(
|
|
create: (context) => ExploreProvider(context.read<CatalogSourcesProvider>()),
|
|
lazy: true,
|
|
),
|
|
Provider(create: (context) => CatalogLibraryMatcher(context.read<MultiServerProvider>()), lazy: true),
|
|
ChangeNotifierProvider(
|
|
create: (context) =>
|
|
HiddenLibrariesProvider(storageService: context.read<StorageService>(), profileId: activeId),
|
|
lazy: true,
|
|
),
|
|
ChangeNotifierProvider(
|
|
create: (context) {
|
|
final activeProfile = context.read<ActiveProfileProvider>();
|
|
return LibrariesProvider(
|
|
storageService: context.read<StorageService>(),
|
|
multiServer: context.read<MultiServerProvider>(),
|
|
isProfileBinding: () => activeProfile.isBinding,
|
|
);
|
|
},
|
|
),
|
|
ChangeNotifierProvider(
|
|
create: (context) {
|
|
final activeProfile = context.read<ActiveProfileProvider>();
|
|
return DiscoverProvider(
|
|
context.read<MultiServerProvider>(),
|
|
context.read<HiddenLibrariesProvider>(),
|
|
context.read<LibrariesProvider>(),
|
|
isProfileBinding: () => activeProfile.isBinding,
|
|
profileId: activeId,
|
|
);
|
|
},
|
|
),
|
|
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
|
|
// Profile-session scope so a profile switch tears the music
|
|
// session down (dispose stops playback + releases the audio
|
|
// core).
|
|
ChangeNotifierProvider<MusicPlaybackService>(
|
|
create: (context) => MusicPlaybackServiceImpl(
|
|
serverManager: context.read<MultiServerProvider>().serverManager,
|
|
database: context.read<AppDatabase>(),
|
|
offlineWatchService: context.read<OfflineWatchSyncService>(),
|
|
),
|
|
),
|
|
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
|
|
ChangeNotifierProvider(
|
|
create: (context) {
|
|
final provider = CompanionRemoteProvider();
|
|
// Keep a running host's crypto identity live: a home user
|
|
// removed or a borrowed connection revoked mid-session must
|
|
// stop controlling the broadcast.
|
|
provider.bindProfileServices(
|
|
connections: context.read<ConnectionRegistry>(),
|
|
activeProfile: context.read<ActiveProfileProvider>(),
|
|
profileConnections: context.read<ProfileConnectionRegistry>(),
|
|
plexHome: context.read<PlexHomeService>(),
|
|
);
|
|
return provider;
|
|
},
|
|
),
|
|
],
|
|
child: _ProfileSessionNavigator(
|
|
isOfflineMode: widget.isOfflineMode,
|
|
initialPromptHandled: initialPromptHandled,
|
|
profileShellBuilder: widget.profileShellBuilder,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProfileSessionNavigator extends StatefulWidget {
|
|
const _ProfileSessionNavigator({
|
|
required this.isOfflineMode,
|
|
required this.initialPromptHandled,
|
|
required this.profileShellBuilder,
|
|
});
|
|
|
|
final bool isOfflineMode;
|
|
final bool initialPromptHandled;
|
|
final WidgetBuilder? profileShellBuilder;
|
|
|
|
@override
|
|
State<_ProfileSessionNavigator> createState() => _ProfileSessionNavigatorState();
|
|
}
|
|
|
|
class _ProfileSessionNavigatorState extends State<_ProfileSessionNavigator> {
|
|
final _navigatorKey = GlobalKey<NavigatorState>();
|
|
final _mainScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
|
final _routeObserver = RouteObserver<PageRoute<dynamic>>();
|
|
|
|
// Music mini-player wiring: the route observer hides the overlay while the
|
|
// video player / now-playing screen is up; the inset controller lets
|
|
// MainScreen report its bottom-bar height so the overlay floats above it.
|
|
final _musicRouteObserver = MusicUiRouteObserver();
|
|
final _miniPlayerInsets = MiniPlayerInsetController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
profileNavigationRegistry.attachNavigator(_navigatorKey);
|
|
profileNavigationRegistry.attachMainScaffoldMessenger(_mainScaffoldMessengerKey);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
profileNavigationRegistry.detachNavigator(_navigatorKey);
|
|
profileNavigationRegistry.detachMainScaffoldMessenger(_mainScaffoldMessengerKey);
|
|
_miniPlayerInsets.dispose();
|
|
_musicRouteObserver.suppress.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ProfileNavigationScope(
|
|
navigatorKey: _navigatorKey,
|
|
routeObserver: _routeObserver,
|
|
mainScaffoldMessengerKey: _mainScaffoldMessengerKey,
|
|
child: PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (didPop) return;
|
|
unawaited(_navigatorKey.currentState?.maybePop());
|
|
},
|
|
child: MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<MiniPlayerInsetController>.value(value: _miniPlayerInsets),
|
|
Provider<MusicUiRouteObserver>.value(value: _musicRouteObserver),
|
|
],
|
|
// The mini-player mounts ABOVE the nested navigator so it persists
|
|
// across content routes (but inside the profile provider scope so
|
|
// it dies with the session).
|
|
child: Stack(
|
|
children: [
|
|
Navigator(
|
|
key: _navigatorKey,
|
|
observers: [_routeObserver, _musicRouteObserver, BackKeySuppressorObserver()],
|
|
onGenerateRoute: _onGenerateRoute,
|
|
),
|
|
const Positioned.fill(child: MusicMiniPlayerOverlay()),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Route<dynamic> _onGenerateRoute(RouteSettings settings) {
|
|
// This navigator's initial route is the profile shell. Content routes are
|
|
// pushed imperatively from inside the shell, so named routes belong to the
|
|
// root navigator unless this method is expanded intentionally.
|
|
final routeName = settings.name;
|
|
if (routeName != null && routeName != Navigator.defaultRouteName) {
|
|
throw FlutterError('ProfileSessionNavigator does not handle named route "$routeName".');
|
|
}
|
|
|
|
return MaterialPageRoute<void>(
|
|
settings: settings,
|
|
builder: (context) =>
|
|
widget.profileShellBuilder?.call(context) ??
|
|
MainScreen(isOfflineMode: widget.isOfflineMode, initialPromptHandled: widget.initialPromptHandled),
|
|
);
|
|
}
|
|
}
|