refactor(player): share one playback-open orchestration between start and reload

_startPlayback and _reloadMediaInPlace each inlined the same ~180-line open sequence around the playback_open.dart helpers — frame-rate prep, display priming, startup gate, external-subtitle plan, open, track manager build, track apply, gate release — with comments instructing that the two copies be kept in sync, and they had already drifted (live _isTranscoding vs result.isTranscoding, Watch Together attach vs detach/reattach).

The sequence now lives once in _openResolvedMedia; the genuine divergences are explicit parameters and caller hooks (transcoding source, WT handling, session-commit boundary, automotive start deferral, resume timing), so a future change to open sequencing lands in both flows by construction. Every await boundary and staleness guard keeps its original position in both flows.
This commit is contained in:
edde746
2026-08-16 16:48:02 +02:00
parent 24f63a63c5
commit 9c6fcd2625
3 changed files with 486 additions and 372 deletions
@@ -735,7 +735,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
}
var subtitleSelection = await _resolveSubtitleSelectionForOpen(
final subtitleSelection = await _resolveSubtitleSelectionForOpen(
metadata: metadata,
result: result,
preferredAudioTrack: initializationAudioTrack,
@@ -751,7 +751,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
// Build the replacement session now, commit it only once open()
// succeeds — until then every session-derived getter still describes
// the item that is actually playing.
var session = PlaybackSession.fromContext(
final session = PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: targetQualityPreset,
requestedMediaSourceId: selectedMediaSourceId,
@@ -761,78 +761,85 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
}
final displayCriteria = result.mediaInfo?.displayCriteria;
final settingsService = await SettingsService.getInstance();
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Same pre-open frame-rate orchestration as the initial start flow —
// including the Android MPV startup decoder refresh, whose gate is
// armed before open and released after track setup below.
final frameRatePlan = await _prepareFrameRateForOpen(
// Shared open orchestration with the initial start flow
// ([_openResolvedMedia]) — including the Android MPV startup decoder
// refresh, whose gate is armed before open and released after track
// setup.
final flow = await _openResolvedMedia(
currentPlayer: currentPlayer,
settingsService: settingsService,
preKnownFps: displayCriteria?.fps,
preKnownWidth: displayCriteria?.width ?? 0,
preKnownHeight: displayCriteria?.height ?? 0,
hasVideoUrl: true,
isTranscoding: result.isTranscoding,
ensureAudioFocus: () => currentPlayer.requestAudioFocus(),
);
if (frameRatePlan == null || !isCurrentReload()) {
return _MediaReloadOutcome.superseded;
}
_frameRate.resetForNewItem();
if (frameRatePlan.countsAsApplied) _frameRate.applied = true;
await _primeDisplayCriteria(
player: currentPlayer,
settingsService: settingsService,
displayCriteria: displayCriteria,
isTranscoding: result.isTranscoding,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final openTiming = _playbackOpenTiming(
isTranscoding: result.isTranscoding,
resumePosition: openResumePosition,
durationMs: metadata.durationMs,
);
_progressTracker?.stopTracking();
_progressTracker?.dispose();
_progressTracker = null;
unawaited(DiscordRPCService.instance.stopPlayback());
unawaited(TrackerCoordinator.instance.stopPlayback());
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Generation invalidation prevents follow-on selection calls, but a
// native audio/subtitle/rate mutation may already have been
// dispatched. Drain exactly that captured operation before reusing
// the player for replacement media, otherwise its late completion can
// mutate the replacement item's tracks.
await attempt.trackMutationDrain;
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
frameRatePlan.armStartupRefreshGate(currentPlayer);
final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: subtitleSelection.sidecarsAtOpen,
);
var effectiveExternalSubtitlePlan = externalSubtitlePlan;
final openResult = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding,
metadata: metadata,
result: result,
session: session,
subtitleSelection: subtitleSelection,
headers: result.usesLocalMedia ? null : streamHeaders,
// Not _isOfflinePlayback: the replacement session commits later, in
// onOpened, so the getter still describes the previous item here.
isLocalMedia: _offlineLibraryMode || result.usesLocalMedia,
selectedVersion: result.selectedVersion,
timing: openTiming,
headers: result.usesLocalMedia ? null : streamHeaders,
// The vehicle is not consulted here: `_openMediaOnPlayer` reads it at the `player.open`
// itself, which is after this and its own awaited tuning work.
play: shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: isCurrentReload,
isCurrent: isCurrentReload,
staleGuard: isCurrentReload,
// Captured before the reload detached the player from the sync
// layer; a live read would see the detached state.
watchTogetherOwnsStart: () => wtOwnsStart,
resolveShouldAutoStart: (_) => shouldAutoStart,
resumePosition: () => openResumePosition,
plexClient: () => plexClient,
getProfileSettings: () => userProfileProvider.profileSettings,
preferredAudioTrack: initializationAudioTrack,
primarySubtitleTranscoding: () => result.isTranscoding,
ensureAudioFocus: () => currentPlayer.requestAudioFocus(),
clearFirstFrameForOpen: false,
deferAutomotiveStart: false,
beforeArm: () async {
if (!isCurrentReload()) return false;
_progressTracker?.stopTracking();
_progressTracker?.dispose();
_progressTracker = null;
unawaited(DiscordRPCService.instance.stopPlayback());
unawaited(TrackerCoordinator.instance.stopPlayback());
if (!isCurrentReload()) return false;
// Generation invalidation prevents follow-on selection calls,
// but a native audio/subtitle/rate mutation may already have
// been dispatched. Drain exactly that captured operation before
// reusing the player for replacement media, otherwise its late
// completion can mutate the replacement item's tracks.
await attempt.trackMutationDrain;
return isCurrentReload();
},
afterMediaOpened: (_, _, _) async {
_completionLatch.reset();
if (isItemChange) {
// Same-item reloads (including the spurious-EOF recovery itself
// and quality switches) keep the spent budget — that is the
// loop guard.
_spuriousEofRecoveryAttempts = 0;
_spuriousEofRecoveryBaselineMs = null;
}
// Versions/mediaInfo come from the committed session; rebuild so
// the controls pick them up. Same-part switches
// (quality/audio/subtitle) keep the scrub-preview source —
// BIF/trickplay is per part, so a reset would re-download
// identical bytes.
final reusesScrubPreview =
previousMetadata.globalKey == metadata.globalKey &&
previousPartId != null &&
previousPartId == result.mediaInfo?.partId;
if (reusesScrubPreview) {
_setPlayerState(() {});
} else {
_resetScrubPreviewForNewItem(metadata: metadata, mediaInfo: result.mediaInfo, mediaClient: mediaClient);
}
_clearEpisodeLoadingFlags();
if (isItemChange) _showChromeForSwappedItem();
_trackManager?.dispose();
return true;
},
onOpening: () {
_hasRenderedFirstFrame = false;
// 503s observed from here on belong to the replacement open.
@@ -845,100 +852,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_commitPlaybackSession(session);
},
);
// A false didOpen means shouldContinue stopped the sequence pre-open
// (open failures throw into the catch below) — superseded either way.
if (!openResult.didOpen || !isCurrentReload()) {
return _MediaReloadOutcome.superseded;
}
if (openResult.sidecarFallbackUsed) {
session = _commitSidecarFallbackSession(session);
subtitleSelection = session.subtitleSelection;
effectiveExternalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: const [],
);
}
_completionLatch.reset();
if (isItemChange) {
// Same-item reloads (including the spurious-EOF recovery itself and
// quality switches) keep the spent budget — that is the loop guard.
_spuriousEofRecoveryAttempts = 0;
_spuriousEofRecoveryBaselineMs = null;
}
// Versions/mediaInfo come from the committed session; rebuild so the
// controls pick them up. Same-part switches (quality/audio/subtitle)
// keep the scrub-preview source — BIF/trickplay is per part, so a
// reset would re-download identical bytes.
final reusesScrubPreview =
previousMetadata.globalKey == metadata.globalKey &&
previousPartId != null &&
previousPartId == result.mediaInfo?.partId;
if (reusesScrubPreview) {
_setPlayerState(() {});
} else {
_resetScrubPreviewForNewItem(metadata: metadata, mediaInfo: result.mediaInfo, mediaClient: mediaClient);
}
_clearEpisodeLoadingFlags();
if (isItemChange) _showChromeForSwappedItem();
_trackManager?.dispose();
final trackManager = _buildTrackManager(
forPlayer: currentPlayer,
metadata: metadata,
plexClient: plexClient,
getProfileSettings: () => userProfileProvider.profileSettings,
preferredAudioTrack: initializationAudioTrack,
// A declined carry stays alive for the native passes: freezing the
// resolver's off verdict here would turn a metadata mismatch into a
// navigation-priority off that no late track can undo (#1785).
preferredSubtitleTrack:
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
// A source-backed primary with no sidecar on a transcode is one the server burned into
// the picture: it is already visible, and no native track will ever arrive to match it.
primarySubtitleIsServerRendered:
result.isTranscoding &&
subtitleSelection.primarySourceStreamId != null &&
subtitleSelection.primarySidecar == null,
);
_trackManager = trackManager;
trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
final resumeForStartupFrame =
shouldAutoStart && frameRatePlan.needsStartupRefresh && effectiveExternalSubtitlePlan.requiresPostOpenAdd;
await _applyTracksAfterOpen(
trackManager: trackManager,
externalSubtitlePlan: effectiveExternalSubtitlePlan,
// Same guard as the start path: don't resume a player a newer flow
// owns, and let a pending startup gate (or Watch Together's group
// start) own the resume instead. Post-open external-subtitle paths
// resume once here so the startup refresh gate can observe a frame.
shouldResumeAfterSubtitleLoad: () =>
shouldAutoStart &&
(!frameRatePlan.holdPlaybackStart || resumeForStartupFrame) &&
mounted &&
player == currentPlayer,
applySelectionWhenResumeSkipped: !shouldAutoStart && !frameRatePlan.holdPlaybackStart,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
await _releaseFrameRateStartupGate(
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
// Paused reloads use the same no-resume branch as an externally
// coordinated start: track selection is armed without manufacturing
// a new play intent.
resumeAfterStartupGate: (reason) => _finishPlaybackAfterStartupGate(
currentPlayer: currentPlayer,
externalSubtitlePlan: effectiveExternalSubtitlePlan,
reason: reason,
shouldResume: shouldAutoStart,
watchTogetherOwnsStart: wtOwnsStart,
),
playbackResumedForStartupFrame: resumeForStartupFrame,
);
if (flow == null) return _MediaReloadOutcome.superseded;
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Same helper as the initial start flow, so any future change lands in
@@ -66,12 +66,33 @@ class _MediaOpenResult {
final bool sidecarFallbackUsed;
}
/// Everything the shared open orchestration ([_openResolvedMedia]) produced
/// that outlives it: the (possibly sidecar-fallback-recomputed) session and
/// subtitle selection, the freshly built per-item track manager, and the
/// plans the open ran under.
class _ResolvedMediaOpenResult {
const _ResolvedMediaOpenResult({
required this.session,
required this.subtitleSelection,
required this.trackManager,
required this.externalSubtitlePlan,
required this.frameRatePlan,
});
final PlaybackSession session;
final PlaybackSubtitleSelection subtitleSelection;
final TrackManager trackManager;
final _ExternalSubtitleOpenPlan externalSubtitlePlan;
final _FrameRateStartupPlan frameRatePlan;
}
/// Shared building blocks for opening media on the live player.
///
/// The initial start flow ([_startPlayback]) and in-place reload flow
/// ([_reloadMediaInPlace]) both route through these helpers so per-open
/// behavior (display priming, frame-rate suppression windows, native
/// subtitle styling, and the open sequence) cannot drift between paths.
/// ([_reloadMediaInPlace]) both route through these helpers — and through
/// the shared [_openResolvedMedia] orchestration — so per-open behavior
/// (display priming, frame-rate suppression windows, native subtitle
/// styling, and the open sequence) cannot drift between paths.
/// This is also the only place that reads
/// [SettingsService.displaySwitchDelay].
extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
@@ -713,4 +734,281 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
await _applyNativeSubtitleStyle(player, settingsService);
return _MediaOpenResult(didOpen: true, sidecarFallbackUsed: sidecarFallbackUsed);
}
/// Shared orchestration for opening a resolved source on the live player:
/// pre-open frame-rate negotiation → per-item frame-rate reset → display
/// priming → startup-gate arming → external-subtitle planning → open →
/// sidecar-fallback session recompute → track-manager build → post-open
/// track application → frame-rate startup-gate release.
///
/// The initial start flow ([_startPlayback]) and the in-place reload flow
/// ([_reloadMediaInPlace]) both run this sequence; caller-specific
/// choreography (session commit boundary, Watch Together attach/detach,
/// progress-tracker teardown, per-screen service setup) stays in the
/// callers and runs through the hooks below at its original position in
/// the sequence. Deliberate per-flow differences are explicit parameters —
/// nothing here may silently unify them.
///
/// Returns null when a staleness guard or hook aborted the flow (the start
/// flow returns silently, the reload flow maps it to superseded); open
/// failures still throw to the caller.
Future<_ResolvedMediaOpenResult?> _openResolvedMedia({
required Player currentPlayer,
required SettingsService settingsService,
// start: _currentMetadata; reload: the replacement item's metadata.
required MediaItem metadata,
required PlaybackInitializationResult result,
required PlaybackSession session,
required PlaybackSubtitleSelection subtitleSelection,
// start: the resolved stream headers; reload drops them for local media.
required Map<String, String>? headers,
// start: _isOfflinePlayback (its session is already committed); reload:
// _offlineLibraryMode || result.usesLocalMedia because its replacement
// session commits later, in [onOpened], so the getter still describes
// the previous item at open time.
required bool isLocalMedia,
// Staleness check used at the shared guard points and as
// [_openMediaOnPlayer]'s shouldContinue. start: attempt.isCurrent;
// reload: isCurrentReload (attempt + fatal-error + exiting).
required bool Function() isCurrent,
// Whether an active Watch Together session owns the (group) start. The
// start flow reads it live right after the frame-rate negotiation (its
// original position); the reload flow captured it before detaching the
// player from the session and replays that value.
required bool Function() watchTogetherOwnsStart,
// Whether this open should end in playing. start: !wtOwnsStart (an
// initial start always intends to play unless the sync layer owns the
// group start); reload: shouldAutoStartReloadedMedia (was-playing /
// startPaused / Watch Together).
required bool Function(bool wtOwnsStart) resolveShouldAutoStart,
// Where playback starts. reload resolves it before the old stop report;
// start resolves it in [beforePrime] (after audio focus, its original
// position) and exposes the value here.
required Duration? Function() resumePosition,
// Plex client for TrackManager's server-side track persistence. reload
// narrows the resolver's reporting client; start derives it from the
// screen's media client inside [beforeTrackSetup].
required PlexClient? Function() plexClient,
required MediaServerUserProfile? Function() getProfileSettings,
// start: the launch-time preference; reload: the carried-over selection.
required AudioTrack? preferredAudioTrack,
// Transcode signal for the server-rendered-primary-subtitle rule. Known
// per-flow drift, kept deliberately: start reads the live session-backed
// _isTranscoding getter (its session committed before this call); reload
// reads result.isTranscoding because its session commits at the open
// boundary.
required bool Function() primarySubtitleTranscoding,
// Audio-focus hook for the pre-open ExoPlayer switch. start memoizes the
// in-flight _audioFocusFuture; reload requests focus directly.
required Future<void> Function() ensureAudioFocus,
// start: true — the flag is dropped here, right before the frame-rate
// reset; reload dropped it earlier, at its eager-identity boundary.
required bool clearFirstFrameForOpen,
// start: true — open never auto-plays on automotive and [afterMediaOpened]
// re-issues the play intent instead; reload: false — the vehicle verdict
// is read inside [_openMediaOnPlayer] at the player.open itself, which is
// after this call and its own awaited tuning work.
required bool deferAutomotiveStart,
// reload-only extra staleness re-checks (right after the frame-rate plan
// and right after track application); the start flow has none there.
bool Function()? staleGuard,
// start-only: audio focus + resume-position resolution between the
// frame-rate reset and display priming. Return false to abort.
Future<bool> Function()? beforePrime,
// reload-only: progress-tracker teardown and the captured track-mutation
// drain between display priming and startup-gate arming. Return false to
// abort.
Future<bool> Function()? beforeArm,
// Runs right after the open boundary (incl. the sidecar-fallback session
// recompute), only when a video URL was opened. start: Watch Together
// attach + deferred automotive start; reload: completion-latch/scrub/
// loading-flag upkeep and disposing the previous track manager. Return
// false to abort.
required Future<bool> Function(bool shouldAutoPlay, bool holdPlaybackStart, bool wtOwnsStart) afterMediaOpened,
// start-only: mounted gate + per-screen service setup (scrub preview,
// video filter/PiP, shaders) between the open branch and the track
// manager build. Return false to abort.
Future<bool> Function()? beforeTrackSetup,
// start-only: reads the Watch Together startup hold its
// [afterMediaOpened] may have created, consumed when the startup gate
// resolves the post-gate playback decision.
Completer<void>? Function()? wtStartupHold,
// Open-boundary callbacks passed through to [_openMediaOnPlayer]: reload
// disarms the 503 watchdog in [onOpening] and commits its session in
// [onOpened]; start tracks primary-media availability for its error
// classification.
void Function()? onOpening,
void Function()? onOpened,
void Function(bool available)? onMediaAvailabilityChanged,
}) async {
final displayCriteria = result.mediaInfo?.displayCriteria;
final frameRatePlan = await _prepareFrameRateForOpen(
currentPlayer: currentPlayer,
settingsService: settingsService,
preKnownFps: displayCriteria?.fps,
preKnownWidth: displayCriteria?.width ?? 0,
preKnownHeight: displayCriteria?.height ?? 0,
hasVideoUrl: result.videoUrl != null,
isTranscoding: result.isTranscoding,
ensureAudioFocus: ensureAudioFocus,
);
if (frameRatePlan == null || (staleGuard != null && !staleGuard())) return null;
final wtOwnsStart = watchTogetherOwnsStart();
final shouldAutoStart = resolveShouldAutoStart(wtOwnsStart);
var openSession = session;
var openSubtitleSelection = subtitleSelection;
late _ExternalSubtitleOpenPlan externalSubtitlePlan;
// Open video through Player
if (result.videoUrl != null) {
// Reset first frame flag and frame rate retry counter for new video
if (clearFirstFrameForOpen) _hasFirstFrame.value = false;
_frameRate.resetForNewItem();
if (frameRatePlan.countsAsApplied) {
_frameRate.applied = true;
}
if (beforePrime != null && !await beforePrime()) return null;
await _primeDisplayCriteria(
player: currentPlayer,
settingsService: settingsService,
displayCriteria: displayCriteria,
isTranscoding: result.isTranscoding,
);
if (beforeArm != null && !await beforeArm()) return null;
frameRatePlan.armStartupRefreshGate(currentPlayer);
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: openSubtitleSelection.sidecarsAtOpen,
);
final shouldAutoPlay =
shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup;
// Backends that support at-open sidecars receive them with open()
// so tracks are discovered in a single prepare/loadfile cycle. Any
// backend that cannot do that still uses the post-open sub-add path.
final openTiming = _playbackOpenTiming(
isTranscoding: result.isTranscoding,
resumePosition: resumePosition(),
durationMs: metadata.durationMs,
);
if (!isCurrent()) return null;
final openResult = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding,
isLocalMedia: isLocalMedia,
selectedVersion: result.selectedVersion,
timing: openTiming,
headers: headers,
play: deferAutomotiveStart ? shouldAutoPlay && !PlatformDetector.isAutomotive() : shouldAutoPlay,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: isCurrent,
onOpening: onOpening,
onOpened: onOpened,
onMediaAvailabilityChanged: onMediaAvailabilityChanged,
);
// A false didOpen means shouldContinue stopped the sequence pre-open;
// open failures throw to the caller instead.
if (!openResult.didOpen || !isCurrent()) return null;
if (openResult.sidecarFallbackUsed) {
openSession = _commitSidecarFallbackSession(openSession);
openSubtitleSelection = openSession.subtitleSelection;
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(player: currentPlayer, externalSubtitles: const []);
}
if (!await afterMediaOpened(shouldAutoPlay, frameRatePlan.holdPlaybackStart, wtOwnsStart)) return null;
} else {
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: openSubtitleSelection.sidecarsAtOpen,
waitForFileLoaded: false,
);
}
if (beforeTrackSetup != null && !await beforeTrackSetup()) return null;
// Track manager: owns track selection, external subtitle loading, and Plex
// immediate stream writes. Jellyfin persists selected stream indexes through
// playback progress reports instead.
final trackManager = _buildTrackManager(
forPlayer: currentPlayer,
metadata: metadata,
plexClient: plexClient(),
getProfileSettings: getProfileSettings,
preferredAudioTrack: preferredAudioTrack,
// A declined preference stays alive for the native passes instead of
// being frozen into off: the resolver's off verdict would turn a
// metadata mismatch into a navigation-priority off that no late track
// can undo (#1785).
preferredSubtitleTrack:
openSubtitleSelection.declinedPreference ??
SubtitlePreference.trackOrNull(openSubtitleSelection.primaryTrack),
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(openSubtitleSelection.secondaryTrack),
// A source-backed primary with no sidecar on a transcode is one the
// server burned into the picture: it is already visible, and no native
// track will ever arrive to match it.
primarySubtitleIsServerRendered:
primarySubtitleTranscoding() &&
openSubtitleSelection.primarySourceStreamId != null &&
openSubtitleSelection.primarySidecar == null,
);
_trackManager = trackManager;
// Store only the active sidecars for re-use after backend fallback.
trackManager.cacheExternalSubtitles(openSubtitleSelection.sidecarsAtOpen);
final resumeForStartupFrame =
shouldAutoStart && frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd;
await _applyTracksAfterOpen(
trackManager: trackManager,
externalSubtitlePlan: externalSubtitlePlan,
// When the startup gate below owns the resume, skip this one to avoid
// a double-play, and never resume a player a newer flow owns. Paused
// and Watch Together-owned starts arm selection through the
// resume-skipped branch instead. Post-open external-subtitle paths are
// the exception: after they attach we must resume once so mpv can
// produce the startup frame the decoder-refresh gate is waiting for.
shouldResumeAfterSubtitleLoad: () =>
shouldAutoStart &&
(!frameRatePlan.holdPlaybackStart || resumeForStartupFrame) &&
mounted &&
player == currentPlayer,
applySelectionWhenResumeSkipped: !shouldAutoStart && !frameRatePlan.holdPlaybackStart,
);
if (staleGuard != null && !staleGuard()) return null;
await _releaseFrameRateStartupGate(
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
// Paused opens use the same no-resume branch as an externally
// coordinated start: track selection is armed without manufacturing a
// new play intent, and a Watch Together owner also gets its readiness
// hold released.
resumeAfterStartupGate: (reason) => _finishPlaybackAfterStartupGate(
currentPlayer: currentPlayer,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
shouldResume: shouldAutoStart,
watchTogetherOwnsStart: wtOwnsStart,
wtStartupHold: wtStartupHold?.call(),
),
playbackResumedForStartupFrame: resumeForStartupFrame,
);
return _ResolvedMediaOpenResult(
session: openSession,
subtitleSelection: openSubtitleSelection,
trackManager: trackManager,
externalSubtitlePlan: externalSubtitlePlan,
frameRatePlan: frameRatePlan,
);
}
}
+111 -209
View File
@@ -156,7 +156,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
}
final result = playbackContext.result;
final streamHeaders = playbackContext.streamHeaders;
var subtitleSelection = await _resolveSubtitleSelectionForOpen(
final subtitleSelection = await _resolveSubtitleSelectionForOpen(
metadata: _currentMetadata,
result: result,
preferredAudioTrack: _preferredAudioTrack,
@@ -167,7 +167,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
// Initial start has no previous session to protect, so commit as soon
// as the resolve lands (reload-style flows commit at the open
// boundary instead).
var session = PlaybackSession.fromContext(
final session = PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: _selectedQualityPreset,
requestedMediaSourceId: _requestedMediaSourceId,
@@ -180,7 +180,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
// when MediaCodec has already produced its first paused frame.
final settingsService = await SettingsService.getInstance();
if (!attempt.isCurrent) return;
final displayCriteria = result.mediaInfo?.displayCriteria;
var audioFocusReady = false;
Future<void> ensureAudioFocus() async {
@@ -195,216 +194,119 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
audioFocusReady = true;
}
final frameRatePlan = await _prepareFrameRateForOpen(
Duration? resumePosition;
PlexClient? plexClientForTracks;
Completer<void>? wtStartupHold;
final flow = await _openResolvedMedia(
currentPlayer: currentPlayer,
settingsService: settingsService,
preKnownFps: displayCriteria?.fps,
preKnownWidth: displayCriteria?.width ?? 0,
preKnownHeight: displayCriteria?.height ?? 0,
hasVideoUrl: result.videoUrl != null,
isTranscoding: result.isTranscoding,
metadata: _currentMetadata,
result: result,
session: session,
subtitleSelection: subtitleSelection,
headers: streamHeaders,
isLocalMedia: _isOfflinePlayback,
isCurrent: () => attempt.isCurrent,
// When a Watch Together session is active the sync layer owns the
// start: open paused everywhere and let the host coordinate one
// simultaneous group start.
watchTogetherOwnsStart: _watchTogetherOwnsPlaybackStart,
resolveShouldAutoStart: (wtOwnsStart) => !wtOwnsStart,
resumePosition: () => resumePosition,
plexClient: () => plexClientForTracks,
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
preferredAudioTrack: _preferredAudioTrack,
primarySubtitleTranscoding: () => _isTranscoding,
ensureAudioFocus: ensureAudioFocus,
clearFirstFrameForOpen: true,
deferAutomotiveStart: true,
beforePrime: () async {
// Request audio focus before starting playback (Android)
// This causes other media apps (Spotify, podcasts, etc.) to pause.
// Fired in parallel with MPV setup in `_initializePlayer`; we await
// the in-flight future here (usually already resolved).
await ensureAudioFocus();
if (!attempt.isCurrent) return false;
resumePosition = await _resolveOpenResumePosition(
metadata: _currentMetadata,
isOffline: _isOfflinePlayback,
offlineWatchService: offlineWatchService,
);
return mounted && player == currentPlayer;
},
afterMediaOpened: (shouldAutoPlay, holdPlaybackStart, wtOwnsStart) async {
// Attach player to Watch Together session for sync (if in session).
// With a frame-rate startup gate pending, sync readiness waits for
// its release so the group start can't fire mid display switch.
if (mounted && !_isOfflinePlayback) {
if (wtOwnsStart && holdPlaybackStart) {
wtStartupHold = Completer<void>();
}
_attachToWatchTogetherSession(startupHold: wtStartupHold?.future);
_notifyWatchTogetherMediaChange();
}
if (shouldAutoPlay && PlatformDetector.isAutomotive()) {
await _playWithPlaybackIntent(currentPlayer);
if (!attempt.isCurrent) return false;
}
return true;
},
beforeTrackSetup: () async {
// Versions/mediaInfo come from the committed session; rebuild so the
// controls pick them up.
if (!mounted) return false;
final mediaClient = context.tryGetMediaClientForServer(serverIdOrNull(_currentMetadata.serverId));
plexClientForTracks = mediaClient is PlexClient ? mediaClient : null;
_resetScrubPreviewForNewItem(
metadata: _currentMetadata,
mediaInfo: result.mediaInfo,
mediaClient: mediaClient,
);
await _initVideoFilterAndPip();
if (!attempt.isCurrent) return false;
if (player == currentPlayer) {
// Auto-PiP: set up callback for API 26-30 path and initial state
if (_autoPipEnabled) {
void autoPipEnteringCallback() {
if (!mounted || player != currentPlayer) return;
_setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering');
_preparePipFiltersForEntry();
}
_autoPipEnteringCallback = autoPipEnteringCallback;
PipService.onAutoPipEntering = autoPipEnteringCallback;
if (currentPlayer.state.playing) {
unawaited(_updateAutoPipState(isPlaying: true));
}
}
// Shader Service (MPV only)
_shaderService = ShaderService(currentPlayer);
if (_shaderService!.isSupported) {
// Ambient Lighting Service
_ambientLightingService = AmbientLightingService(currentPlayer);
_shaderService!.ambientLightingService = _ambientLightingService;
_videoFilterManager?.ambientLightingService = _ambientLightingService;
await _applySavedShaderPreset();
await _restoreAmbientLighting();
}
}
return attempt.isCurrent;
},
wtStartupHold: () => wtStartupHold,
onMediaAvailabilityChanged: (available) => primaryMediaOpened = available,
);
if (frameRatePlan == null) return;
final shouldHoldPlaybackStart = frameRatePlan.holdPlaybackStart;
// When a Watch Together session is active the sync layer owns the
// start: open paused everywhere and let the host coordinate one
// simultaneous group start.
final wtOwnsStart = _watchTogetherOwnsPlaybackStart();
Completer<void>? wtStartupHold;
late _ExternalSubtitleOpenPlan externalSubtitlePlan;
// Open video through Player
if (result.videoUrl != null) {
// Reset first frame flag and frame rate retry counter for new video
_hasFirstFrame.value = false;
_frameRate.resetForNewItem();
if (frameRatePlan.countsAsApplied) {
_frameRate.applied = true;
}
// Request audio focus before starting playback (Android)
// This causes other media apps (Spotify, podcasts, etc.) to pause.
// Fired in parallel with MPV setup in `_initializePlayer`; we await
// the in-flight future here (usually already resolved).
await ensureAudioFocus();
if (!attempt.isCurrent) return;
final resumePosition = await _resolveOpenResumePosition(
metadata: _currentMetadata,
isOffline: _isOfflinePlayback,
offlineWatchService: offlineWatchService,
);
if (!mounted || player != currentPlayer) return;
await _primeDisplayCriteria(
player: currentPlayer,
settingsService: settingsService,
displayCriteria: displayCriteria,
isTranscoding: result.isTranscoding,
);
frameRatePlan.armStartupRefreshGate(currentPlayer);
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: subtitleSelection.sidecarsAtOpen,
);
final shouldAutoPlay =
!shouldHoldPlaybackStart && !wtOwnsStart && externalSubtitlePlan.canStartBeforeTrackSetup;
// Backends that support at-open sidecars receive them with open()
// so tracks are discovered in a single prepare/loadfile cycle. Any
// backend that cannot do that still uses the post-open sub-add path.
final openTiming = _playbackOpenTiming(
isTranscoding: result.isTranscoding,
resumePosition: resumePosition,
durationMs: _currentMetadata.durationMs,
);
if (!attempt.isCurrent) return;
final openResult = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding,
isLocalMedia: _isOfflinePlayback,
selectedVersion: result.selectedVersion,
timing: openTiming,
headers: streamHeaders,
play: shouldAutoPlay && !PlatformDetector.isAutomotive(),
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: () => attempt.isCurrent,
onMediaAvailabilityChanged: (available) => primaryMediaOpened = available,
);
if (!openResult.didOpen || !attempt.isCurrent) return;
if (openResult.sidecarFallbackUsed) {
session = _commitSidecarFallbackSession(session);
subtitleSelection = session.subtitleSelection;
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(player: currentPlayer, externalSubtitles: const []);
}
// Attach player to Watch Together session for sync (if in session).
// With a frame-rate startup gate pending, sync readiness waits for
// its release so the group start can't fire mid display switch.
if (mounted && !_isOfflinePlayback) {
if (wtOwnsStart && shouldHoldPlaybackStart) {
wtStartupHold = Completer<void>();
}
_attachToWatchTogetherSession(startupHold: wtStartupHold?.future);
_notifyWatchTogetherMediaChange();
}
if (shouldAutoPlay && PlatformDetector.isAutomotive()) {
await _playWithPlaybackIntent(currentPlayer);
if (!attempt.isCurrent) return;
}
} else {
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: subtitleSelection.sidecarsAtOpen,
waitForFileLoaded: false,
);
}
// Versions/mediaInfo come from the committed session; rebuild so the
// controls pick them up.
if (mounted) {
final mediaClient = context.tryGetMediaClientForServer(serverIdOrNull(_currentMetadata.serverId));
_resetScrubPreviewForNewItem(metadata: _currentMetadata, mediaInfo: result.mediaInfo, mediaClient: mediaClient);
await _initVideoFilterAndPip();
if (!attempt.isCurrent) return;
if (player == currentPlayer) {
// Auto-PiP: set up callback for API 26-30 path and initial state
if (_autoPipEnabled) {
void autoPipEnteringCallback() {
if (!mounted || player != currentPlayer) return;
_setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering');
_preparePipFiltersForEntry();
}
_autoPipEnteringCallback = autoPipEnteringCallback;
PipService.onAutoPipEntering = autoPipEnteringCallback;
if (currentPlayer.state.playing) {
unawaited(_updateAutoPipState(isPlaying: true));
}
}
// Shader Service (MPV only)
_shaderService = ShaderService(currentPlayer);
if (_shaderService!.isSupported) {
// Ambient Lighting Service
_ambientLightingService = AmbientLightingService(currentPlayer);
_shaderService!.ambientLightingService = _ambientLightingService;
_videoFilterManager?.ambientLightingService = _ambientLightingService;
await _applySavedShaderPreset();
await _restoreAmbientLighting();
}
}
if (!attempt.isCurrent) return;
// Track manager: owns track selection, external subtitle loading, and Plex
// immediate stream writes. Jellyfin persists selected stream indexes through
// playback progress reports instead.
_trackManager = _buildTrackManager(
forPlayer: currentPlayer,
metadata: _currentMetadata,
plexClient: mediaClient is PlexClient ? mediaClient : null,
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
preferredAudioTrack: _preferredAudioTrack,
// Same rule as the reload flow: a declined preference is retried by
// the native passes instead of being frozen into off (#1785).
preferredSubtitleTrack:
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
// Same rule as the reload flow: a source-backed primary with no sidecar on a transcode is
// burned into the picture, so nothing native is coming for it.
primarySubtitleIsServerRendered:
_isTranscoding &&
subtitleSelection.primarySourceStreamId != null &&
subtitleSelection.primarySidecar == null,
);
// Store only the active sidecars for re-use after backend fallback.
_trackManager!.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
final resumeForStartupFrame =
frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd && !wtOwnsStart;
await _applyTracksAfterOpen(
trackManager: _trackManager!,
externalSubtitlePlan: externalSubtitlePlan,
// When a startup gate below owns the resume, skip this one to
// avoid a double-play. Post-open external-subtitle paths are the
// exception: after they attach we must resume once so mpv can
// produce the startup frame that the decoder-refresh gate is waiting
// for.
// Watch Together stays paused for the group start, so selection is
// armed through the resume-skipped branch.
shouldResumeAfterSubtitleLoad: () =>
(!shouldHoldPlaybackStart || resumeForStartupFrame) && !wtOwnsStart && mounted && player == currentPlayer,
applySelectionWhenResumeSkipped: wtOwnsStart && !shouldHoldPlaybackStart,
);
await _releaseFrameRateStartupGate(
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
resumeAfterStartupGate: (reason) => _finishPlaybackAfterStartupGate(
currentPlayer: currentPlayer,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
shouldResume: !wtOwnsStart,
watchTogetherOwnsStart: wtOwnsStart,
wtStartupHold: wtStartupHold,
),
playbackResumedForStartupFrame: resumeForStartupFrame,
);
// Backstop: if the gate never ran its resume path (unmounted race),
// don't leave Watch Together readiness held forever.
if (wtStartupHold != null && !wtStartupHold.isCompleted) {
wtStartupHold.complete();
}
if (flow == null) return;
// Backstop: if the gate never ran its resume path (unmounted race),
// don't leave Watch Together readiness held forever.
final startupHold = wtStartupHold;
if (startupHold != null && !startupHold.isCompleted) {
startupHold.complete();
}
} on PlaybackException catch (e, st) {
appLogger.w('Playback initialization failed', error: e, stackTrace: st);