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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user