Pausing an episode on one device, finishing it on another and pressing Refresh left the first device showing the old "minutes left". Restarting the app showed the right value. Two independent defects produce that, and either alone reproduces the report. The first is the watch-state overlay. Every local watch event lands in WatchStateStore as a patch, and WatchStateSnapshot.apply overwrites viewOffsetMs unconditionally; isNewerThan only ever orders one patch against another, never against the server row underneath. Nothing expires a patch and nothing clears the map except a profile switch, so the Mac's own paused position kept winning over every subsequent fetch until the process died. A patch exists to bridge the gap between a local action and the next server read of that item, so it should stop applying once that read happens. The store now records the watermark at which a successful authoritative response returned each key, and suppresses an acknowledged session patch at or below it. Only a watermark is stored, never the observed state: WatchStateSnapshot cannot hold a container's leaf counts, and keeping max() per key makes the order two concurrent responses complete irrelevant. Suppression is a read-time predicate, so nothing mutates during build. The barrier covers the parentChain too. patchForItem picks the newest of the item's own entry and its ancestors', so retiring only the item's entry would let an older season mark win and render watched/0 -- worse than either the stale value or the fresh one. An authoritative read of a child already reflects any container mark that preceded it, so the child's observation judges its ancestors as well; a newer container action still wins. Provenance decides what may be suppressed at all. WatchStateEvent now carries serverAcknowledged, defaulting to false so an unclassified emit site degrades to today's behaviour rather than silently becoming retireable. An offline write is owed to the server and a read must never retire it, so it stays until a WatchPatchPromotionNotifier promotion says the queue replayed it. That channel is deliberately not a WatchStateEvent: OfflineWatchSyncService reacts to watched/unwatched by purging queued progress, so replaying one there would delete a newer rewatch. Promotion matches an exact WatchPatchId -- session minted for live crossings, derived from the persisted (profile, row, revision) for queued ones so it still joins after a restart. Report acceptance is not delivery: PlaybackReportSession resolves true for a same-state startup heartbeat it drops, so acknowledgement now keys on onDelivered. A MediaBrowser Started saves play count and last-played date but not the position, so it cannot acknowledge an offset. No report-derived watched crossing is acknowledged on any backend -- Jellyfin hard-codes its threshold and Plex never loads the server pref that would tell it the real one -- so only an awaited explicit markWatched settles one. The second defect is that a failed Refresh reported success. Plex _fetchHubs and the Jellyfin hub legs both degrade a failure to an empty list, and the library prefetch discarded its failures, so a server whose every hub request failed was recorded as succeeded; DiscoverProvider then kept the previous rows, set loaded and surfaced nothing. Worse, the background Continue Watching refresh wiped the row outright on zero success. Hub legs now report what they degraded through a HubFetchDiagnostics sink, which keeps partial rows alongside the failure and leaves every existing caller untouched. Failures ride through the aggregation results, a leg that could not run because discovery failed contributes that failure rather than a successful no-op, and loaded-server ids became succeeded - failed - cancelled so one bad leg no longer caches a server as covered and blocks its retry. The toolbar awaits a DiscoverRefreshOutcome and shows the existing unableToLoad snackbar on failure while the retained rows stay on screen. Rollback after a mid-pass exception is version-guarded, refilters against the current hidden libraries and no longer publishes a system shelf the pass never committed. Observations are staged with the pass and flushed only once the same disposal, generation and exception checks that authorise committing those rows have passed, so a discarded or rolled-back response can never suppress a patch. Also fixes a live data-loss race the promotion work would have built on: upsertProgressAction stamped a millisecond timestamp and updated the row in place, so a rewatch queued during an in-flight replay was deleted by id. Revisions are now strictly monotonic per row, replay deletes and retry updates compare against them, and the upsert resets the retry fields because a new revision is a new logical action. close #1829
371 lines
16 KiB
Dart
371 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/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 = 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;
|
|
},
|
|
),
|
|
ChangeNotifierProxyProvider3<
|
|
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, trackers, seerr, activeProfile, previous) {
|
|
final provider = previous ?? _createCatalogSourcesProvider(context);
|
|
provider.update(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>(),
|
|
// Created above in this same subtree, so its lifetime
|
|
// matches; the proxy reuses `previous`, so the reference
|
|
// stays valid for as long as this provider does.
|
|
watchStateStore: context.read<WatchStateStore>(),
|
|
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),
|
|
);
|
|
}
|
|
}
|