fix(screens): claim the mandatory profile picker before awaiting and skip the full-screen loader after playback

The mandatory profile picker checked its re-entry flag before several awaits, so an initialization notification could stack two requireSelection routes; the flag is now claimed before the first await and the late-profiles edge only fires on a real transition. Returning from playback also reran the full metadata loader, replacing the whole detail screen with a spinner and resetting the selected season; playback returns now use the non-loading watch-state refresh.
This commit is contained in:
edde746
2026-08-21 19:23:41 +02:00
parent 7d0e04bcc0
commit 0f5cd11c08
5 changed files with 315 additions and 38 deletions
+81 -32
View File
@@ -284,6 +284,68 @@ ProfileInvalidationAction profileInvalidationAction({
return ProfileInvalidationAction.none;
}
/// The initial-profile prompt flow behind [_MainScreenState]'s post-frame
/// prompt and its late-profiles re-arm, extracted so the mid-await re-entry
/// race is testable without the full MainScreen tree.
///
/// [claimPrompt] must synchronously claim the "picker is up" guard and return
/// false when it is already claimed. The claim is taken before the first
/// await: the provider notifies mid-initialize (e.g. a fresh sign-in's slow
/// home-user fetch), and the listener's re-entrant call would otherwise stack
/// a second requireSelection picker on the root navigator. [releasePrompt]
/// clears the claim on every exit; on the push path that re-clear is
/// idempotent with [pushProfileSelection]'s own post-pop clear.
@visibleForTesting
Future<void> runInitialProfilePrompt({
required ActiveProfileProvider activeProfile,
required bool Function() claimPrompt,
required void Function() releasePrompt,
required bool Function() isMounted,
required bool isOfflineMode,
required Future<bool> Function() hasConnections,
required Future<void> Function() settleSession,
required Future<void> Function() pushProfileSelection,
Future<SettingsService> Function() settings = SettingsService.getInstance,
}) async {
if (!claimPrompt()) return;
try {
// The provider's initialize() is fire-and-forget from MultiProvider —
// wait for it to settle so `active` and `profiles` reflect storage
// before we decide whether to prompt.
await activeProfile.initialize();
if (!isMounted()) return;
final settingsService = await settings();
if (!isMounted()) return;
// Connections but ZERO resolvable profiles (e.g. the home-user fetch
// failed at sign-in): a session with nothing to select and no picker is
// a dead end. Mirror the boot guard — prune orphans and route to auth
// when nothing selectable remains.
if (activeProfile.active == null && activeProfile.profiles.isEmpty) {
// Offline, "unresolvable" may just be an unreachable plex.tv — don't
// kick the user to auth over it.
if (!isOfflineMode && await hasConnections() && isMounted()) {
appLogger.w('MainScreen: connections exist but no profiles resolved — settling session');
await settleSession();
}
return;
}
// Always prompt when there's no active profile but profiles exist
// (fresh sign-in with multiple Plex Home users): otherwise the binder
// has no profile to bind, and the user lands on an empty screen with
// no way back to the picker.
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
if (!hasNoActive && !activeProfile.requiresSelectionOnOpen(settingsService)) return;
await pushProfileSelection();
} finally {
releasePrompt();
}
}
class MainScreen extends StatefulWidget {
final bool isOfflineMode;
@@ -483,6 +545,11 @@ class _MainScreenState extends State<MainScreen>
final activeProfile = context.read<ActiveProfileProvider>();
_activeProfileForListener = activeProfile;
_lastSeenProfileId = activeProfile.activeId;
// Prime the late-profiles edge alongside _lastSeenProfileId so the
// "!_hadProfiles && hasProfilesNow" check in _onActiveProfileChanged
// fires only on a genuine empty -> non-empty transition, not on the
// first notification of a session that started with profiles.
_hadProfiles = activeProfile.profiles.isNotEmpty;
activeProfile.addListener(_onActiveProfileChanged);
_plexHomeService = context.read<PlexHomeService>();
unawaited(_plexHomeService!.start());
@@ -680,38 +747,20 @@ class _MainScreenState extends State<MainScreen>
final activeProfile = context.read<ActiveProfileProvider>();
final connections = context.read<ConnectionRegistry>();
// The provider's initialize() is fire-and-forget from MultiProvider —
// wait for it to settle so `active` and `profiles` reflect storage
// before we decide whether to prompt.
await activeProfile.initialize();
if (!mounted) return;
final settingsService = await SettingsService.getInstance();
if (!mounted) return;
// Connections but ZERO resolvable profiles (e.g. the home-user fetch
// failed at sign-in): a session with nothing to select and no picker is
// a dead end. Mirror the boot guard — prune orphans and route to auth
// when nothing selectable remains.
if (activeProfile.active == null && activeProfile.profiles.isEmpty) {
// Offline, "unresolvable" may just be an unreachable plex.tv — don't
// kick the user to auth over it.
if (!widget.isOfflineMode && (await connections.list()).isNotEmpty && mounted) {
appLogger.w('MainScreen: connections exist but no profiles resolved — settling session');
await settleSessionAfterRemoval(SessionTeardownScope.of(context));
}
return;
}
// Always prompt when there's no active profile but profiles exist
// (fresh sign-in with multiple Plex Home users): otherwise the binder
// has no profile to bind, and the user lands on an empty screen with
// no way back to the picker.
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
if (!hasNoActive && !activeProfile.requiresSelectionOnOpen(settingsService)) return;
await _pushProfileSelection();
await runInitialProfilePrompt(
activeProfile: activeProfile,
claimPrompt: () {
if (_isShowingProfileSelection) return false;
_isShowingProfileSelection = true;
return true;
},
releasePrompt: () => _isShowingProfileSelection = false,
isMounted: () => mounted,
isOfflineMode: widget.isOfflineMode,
hasConnections: () async => (await connections.list()).isNotEmpty,
settleSession: () => settleSessionAfterRemoval(SessionTeardownScope.of(context)),
pushProfileSelection: _pushProfileSelection,
);
}
/// Push the picker in "must choose" mode, suppressing the tvOS menu-button
+4 -4
View File
@@ -51,7 +51,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
context,
metadata: _onDeckEpisode!,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
onRefresh: _refreshWatchState,
);
} else {
// No on deck episode, fetch first episode of first season
@@ -64,7 +64,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
context,
metadata: _episodes.first,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
onRefresh: _refreshWatchState,
);
} else {
await _playFirstEpisode();
@@ -76,7 +76,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
context,
metadata: metadata,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
onRefresh: _refreshWatchState,
);
}
}
@@ -85,7 +85,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
final didNavigate = await promptAndPlayVersion(context, metadata);
// Same post-playback refresh plain Play gets from
// navigateToVideoPlayerWithRefresh; the split segment is online-only.
if (didNavigate && mounted) unawaited(_loadFullMetadata());
if (didNavigate && mounted) unawaited(_refreshWatchState());
}
final primaryTrailer = _getPrimaryTrailer();
+2 -2
View File
@@ -2937,7 +2937,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
context,
metadata: episodeWithServerId,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
onRefresh: _refreshWatchState,
);
}
} catch (e) {
@@ -2963,7 +2963,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final launcher = MediaListPlaybackLauncher.forItem(context, metadata);
final result = await launcher.launchShuffledShow(metadata: metadata);
if (result is PlayQueueSuccess && mounted) {
unawaited(_loadFullMetadata());
unawaited(_refreshWatchState());
}
}