Files
plezy/lib/screens/video_player/parts/playback_open.dart
T

659 lines
28 KiB
Dart

part of '../../video_player_screen.dart';
/// Outcome of the Android pre-open frame-rate negotiation for the initial
/// start flow: which pre-switch ran, whether playback must open paused
/// behind a startup gate, and which post-open follow-up (fallback switch
/// or mpv decoder refresh) releases it.
class _FrameRateStartupPlan {
_FrameRateStartupPlan({required this.fps, this.width = 0, this.height = 0});
final double? fps;
/// Native video dimensions, so a display-mode fallback can avoid downscaling
/// the video below its resolution just to match cadence (0 = unknown).
final int width;
final int height;
bool attemptedMpvPreLoad = false;
bool didPreLoadSwitch = false;
bool preOpenExoHandled = false;
bool needsPostOpenSwitch = false;
bool needsStartupRefresh = false;
Future<bool>? _startupFrameReady;
/// Whether playback must open paused behind a startup gate that
/// [_releaseFrameRateStartupGate] resumes.
bool get holdPlaybackStart => needsPostOpenSwitch || needsStartupRefresh;
/// Whether the pre-open negotiation already counts as the per-item
/// switch — keeps the post-first-frame fallback from double-switching
/// while a planned follow-up is still pending.
bool get countsAsApplied => didPreLoadSwitch || attemptedMpvPreLoad || preOpenExoHandled;
/// Subscribe to the first rendered frame *before* open() so the startup
/// decoder refresh can't miss a synchronously-fast restart event.
void armStartupRefreshGate(Player player) {
if (!needsStartupRefresh) return;
appLogger.d('Frame rate matching: opening Android MPV paused for startup decoder refresh');
_startupFrameReady = player.streams.playbackRestart.first
.then((_) => true)
.timeout(
const Duration(seconds: 15),
onTimeout: () {
appLogger.w('Timed out waiting for Android MPV startup frame before decoder refresh');
return false;
},
);
}
}
class _ExternalSubtitleOpenPlan {
const _ExternalSubtitleOpenPlan({required this.externalSubtitles, required this.attachesAtOpen, this.readyAfterOpen});
final List<SubtitleTrack> externalSubtitles;
final bool attachesAtOpen;
final Future<void>? readyAfterOpen;
bool get hasExternalSubtitles => externalSubtitles.isNotEmpty;
bool get requiresPostOpenAdd => !attachesAtOpen && hasExternalSubtitles;
bool get canStartBeforeTrackSetup => attachesAtOpen || !hasExternalSubtitles;
List<SubtitleTrack>? get subtitlesAtOpen => attachesAtOpen && hasExternalSubtitles ? externalSubtitles : null;
}
class _MediaOpenResult {
const _MediaOpenResult({required this.didOpen, this.sidecarFallbackUsed = false});
final bool didOpen;
final bool sidecarFallbackUsed;
}
/// 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.
/// This is also the only place that reads
/// [SettingsService.displaySwitchDelay].
extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
PlaybackSession _commitSidecarFallbackSession(PlaybackSession session) {
return _updatePlaybackSessionSubtitleSelection(session, const PlaybackSubtitleSelection.off());
}
Future<PlaybackSubtitleSelection> _resolveSubtitleSelectionForOpen({
required MediaItem metadata,
required PlaybackInitializationResult result,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
}) async {
await _waitForProfileSettingsIfNeeded();
if (!mounted) return const PlaybackSubtitleSelection.off();
return PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: result.mediaInfo,
sidecars: result.subtitleSidecars,
profileSettings: context.read<UserProfileProvider>().profileSettings,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
);
}
/// Prime native display matching (tvOS HDMI mode) from server metadata
/// before the decoder emits stream properties. The native side resolves
/// only after any resulting display-mode switch has settled, plus the
/// user-configured extra delay on Apple TV.
Future<void> _primeDisplayCriteria({
required Player player,
required SettingsService settingsService,
required MediaDisplayCriteria? displayCriteria,
required bool isTranscoding,
}) {
return player.setDisplayCriteria(
!isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null,
extraDelayMs: PlatformDetector.isAppleTV() ? settingsService.read(SettingsService.displaySwitchDelay) * 1000 : 0,
);
}
/// Ask the platform to renegotiate the display refresh rate for [fps],
/// arming the MediaSession pause-suppression window first. The native call
/// returns only after the real display-change event (+ settle + the
/// user-configured delay). Returns whether a switch was initiated.
Future<bool> _switchDisplayFrameRateForOpen({
required Player player,
required SettingsService settingsService,
required double fps,
required int durationMs,
int videoWidth = 0,
int videoHeight = 0,
}) {
final delaySec = settingsService.read(SettingsService.displaySwitchDelay);
_frameRate.beginSuppressWindow(delaySec);
return player.setVideoFrameRate(
fps,
durationMs,
extraDelayMs: delaySec * 1000,
videoWidth: videoWidth,
videoHeight: videoHeight,
);
}
/// Whether the Android pre-open frame-rate negotiation applies: the user
/// opted into per-content refresh-rate matching and metadata already told
/// us the target fps. Shared by the start and reload flows so the
/// eligibility rule cannot drift between them.
bool _shouldAutoSwitchFrameRateForOpen(SettingsService settingsService, double? fps) {
return Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate) && fps != null && fps > 0;
}
/// Resolve where a fresh open should start: explicit request → locally
/// tracked offline progress → server view offset.
Future<Duration?> _resolveOpenResumePosition({
required MediaItem metadata,
required bool isOffline,
required OfflineWatchSyncService offlineWatchService,
Duration? requested,
}) async {
if (requested != null) return requested;
// In offline mode, prefer locally tracked progress over the cached server
// value since the user may have watched further since downloading.
if (isOffline) {
final localOffset = await offlineWatchService.getLocalViewOffset(metadata.globalKey);
if (localOffset != null && localOffset > 0) {
appLogger.d('Resuming offline playback from local progress: ${localOffset}ms');
return Duration(milliseconds: localOffset);
}
}
return metadata.viewOffsetMs != null ? Duration(milliseconds: metadata.viewOffsetMs!) : null;
}
/// Run the Android pre-open frame-rate strategy for the initial start:
/// mpv switches before load (its decoder must start after the mode change,
/// then gets a startup refresh); ExoPlayer switches before open (after
/// audio focus, so AudioTrack passthrough survives the renegotiation);
/// anything that could not switch up front falls back to a post-open
/// switch that holds playback start. Returns null when the screen/player
/// went stale mid-switch and the caller must bail.
Future<_FrameRateStartupPlan?> _prepareFrameRateForOpen({
required Player currentPlayer,
required SettingsService settingsService,
required double? preKnownFps,
required bool hasVideoUrl,
required Future<void> Function() ensureAudioFocus,
int preKnownWidth = 0,
int preKnownHeight = 0,
}) async {
final plan = _FrameRateStartupPlan(fps: preKnownFps, width: preKnownWidth, height: preKnownHeight);
final willAutoSwitch = _shouldAutoSwitchFrameRateForOpen(settingsService, preKnownFps);
// willAutoSwitch is Android-only, so the strategy fork below is between
// the two Android backends: mpv needs its decoder refreshed after a
// display switch (pre-load path), ExoPlayer switches pre-open instead.
final isAndroidMpv = currentPlayer.needsDecoderRefreshAfterDisplaySwitch;
final needsMpvPreLoad = willAutoSwitch && isAndroidMpv && hasVideoUrl;
final needsExoPreOpen = willAutoSwitch && !isAndroidMpv && hasVideoUrl;
plan.needsPostOpenSwitch = willAutoSwitch && !needsMpvPreLoad && !needsExoPreOpen;
plan.attemptedMpvPreLoad = needsMpvPreLoad;
// MPV on Android can decode and present its first paused frame before a
// post-open display switch settles. Switch first when metadata already
// gives us the FPS so MediaCodec starts after the display mode change.
if (needsMpvPreLoad) {
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
try {
appLogger.d('Frame rate matching: pre-load MPV switch to ${preKnownFps}fps (duration: ${durationMs}ms)');
plan.didPreLoadSwitch = await _switchDisplayFrameRateForOpen(
player: currentPlayer,
settingsService: settingsService,
fps: preKnownFps!,
durationMs: durationMs,
videoWidth: plan.width,
videoHeight: plan.height,
);
if (!mounted || player != currentPlayer) return null;
if (plan.didPreLoadSwitch) {
_frameRate.applied = true;
plan.needsStartupRefresh = true;
}
appLogger.d(
'Frame rate matching: pre-load MPV switch complete '
'(switched=${plan.didPreLoadSwitch}, startupRefresh=${plan.needsStartupRefresh})',
);
} catch (e) {
appLogger.w('Failed to apply pre-load MPV frame rate matching', error: e);
plan.needsPostOpenSwitch = true;
plan.needsStartupRefresh = false;
}
}
// ExoPlayer prepares AudioTrack during open() even when opened paused.
// On Shield/AVR chains, switching HDMI refresh rate after that can break
// direct passthrough, so switch before ExoPlayer creates renderers.
if (needsExoPreOpen) {
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
try {
await ensureAudioFocus();
if (!mounted || player != currentPlayer) return null;
appLogger.d('Frame rate matching: pre-open ExoPlayer switch to ${preKnownFps}fps (duration: ${durationMs}ms)');
final didSwitch = await _switchDisplayFrameRateForOpen(
player: currentPlayer,
settingsService: settingsService,
fps: preKnownFps!,
durationMs: durationMs,
videoWidth: plan.width,
videoHeight: plan.height,
);
if (!mounted || player != currentPlayer) return null;
plan.preOpenExoHandled = true;
appLogger.d('Frame rate matching: pre-open ExoPlayer switch complete (switched=$didSwitch)');
} catch (e) {
appLogger.w('Failed to apply pre-open ExoPlayer frame rate matching', error: e);
plan.needsPostOpenSwitch = true;
plan.preOpenExoHandled = false;
}
}
return plan;
}
/// Release the startup gate a [_FrameRateStartupPlan] held playback
/// behind: run the post-open fallback switch, or wait for the first
/// rendered frame and refresh the mpv decoder, then resume via
/// [resumeAfterStartupGate].
Future<void> _releaseFrameRateStartupGate({
required Player currentPlayer,
required SettingsService settingsService,
required _FrameRateStartupPlan plan,
required Future<void> Function(String reason) resumeAfterStartupGate,
bool playbackResumedForStartupFrame = false,
}) async {
Future<void> resumeAfterRefresh(String reason) async {
if (playbackResumedForStartupFrame) {
appLogger.d('Frame rate matching: continuing already-resumed playback after $reason');
await _playWithPlaybackIntent(currentPlayer);
} else {
await resumeAfterStartupGate(reason);
}
}
// Fallback refresh-rate path. The player was opened paused;
// setVideoFrameRate awaits the real display-change event (+ settle +
// user delay) before returning, then we start playback.
if (plan.needsPostOpenSwitch && mounted && player == currentPlayer) {
_frameRate.applied = true;
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
bool didSwitch = false;
try {
didSwitch = await _switchDisplayFrameRateForOpen(
player: currentPlayer,
settingsService: settingsService,
fps: plan.fps!,
durationMs: durationMs,
videoWidth: plan.width,
videoHeight: plan.height,
);
if (!mounted || player != currentPlayer) return;
if (didSwitch) {
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'post-open frame rate switch');
}
} catch (e) {
appLogger.w('Failed to apply pre-playback frame rate matching', error: e);
}
// Always resume — either the switch completed and we want to play,
// or no switch was needed and we need to start playback now that the
// preparation gate has been cleared.
await resumeAfterRefresh('post-open frame rate switch');
unawaited(
Sentry.addBreadcrumb(
Breadcrumb(message: 'Pre-playback frame rate: ${plan.fps}fps, switched=$didSwitch', category: 'player'),
),
);
} else if (plan.needsStartupRefresh && mounted && player == currentPlayer) {
appLogger.d('Frame rate matching: waiting for Android MPV startup frame before decoder refresh');
final startupFrameReady = plan._startupFrameReady;
final startupReady = startupFrameReady == null ? false : await startupFrameReady;
if (mounted && player == currentPlayer) {
if (startupReady) {
await Future<void>.delayed(const Duration(milliseconds: 100));
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'pre-load frame rate startup');
await resumeAfterRefresh('startup decoder refresh');
} else {
appLogger.w('Frame rate matching: skipping Android MPV decoder refresh because startup frame timed out');
await resumeAfterRefresh('startup frame timeout');
}
}
unawaited(
Sentry.addBreadcrumb(
Breadcrumb(
message: 'Android MPV startup decoder refresh after pre-load frame-rate switch',
category: 'player',
),
),
);
}
}
/// Resume playback once a frame-rate startup gate releases: a pending
/// post-open external-subtitle load resumes through the track manager
/// (which also arms selection), everything else plays directly. Shared by
/// the start and reload flows.
Future<void> _resumeAfterFrameRateStartupGate({
required Player currentPlayer,
required _ExternalSubtitleOpenPlan externalSubtitlePlan,
required String reason,
}) async {
if (!mounted || player != currentPlayer) return;
final trackManager = _trackManager;
if (trackManager == null) return;
appLogger.d('Frame rate matching: resuming playback after $reason');
_playbackIntentShouldPlay = true;
if (externalSubtitlePlan.requiresPostOpenAdd) {
await trackManager.resumeAfterSubtitleLoad();
} else {
await _playWithPlaybackIntent(currentPlayer);
}
}
/// Resolves the post-gate playback decision without inventing a play
/// intent. Track selection is still armed when playback must remain paused;
/// a Watch Together owner also receives its readiness release.
Future<void> _finishPlaybackAfterStartupGate({
required Player currentPlayer,
required _ExternalSubtitleOpenPlan externalSubtitlePlan,
required String reason,
required bool shouldResume,
required bool watchTogetherOwnsStart,
Completer<void>? wtStartupHold,
}) async {
if (shouldResume) {
return _resumeAfterFrameRateStartupGate(
currentPlayer: currentPlayer,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
);
}
appLogger.d(
watchTogetherOwnsStart
? 'Frame rate matching: yielding post-gate resume to Watch Together ($reason)'
: 'Frame rate matching: preserving paused playback after $reason',
);
final trackManager = _trackManager;
if (trackManager != null && externalSubtitlePlan.requiresPostOpenAdd) {
trackManager.waitingForExternalSubsTrackSelection = false;
trackManager.applyTrackSelectionWhenReady();
}
if (watchTogetherOwnsStart && wtStartupHold != null && !wtStartupHold.isCompleted) {
wtStartupHold.complete();
}
}
/// Push the user's subtitle style to the native rendering layer (no-op on
/// mpv backends, which style via `sub-*` properties). Must run after
/// open() since that's when ExoPlayer initializes its subtitle views.
Future<void> _applyNativeSubtitleStyle(Player player, SettingsService settingsService) {
return player.setSubtitleStyle(
fontSize: settingsService.read(SettingsService.subtitleFontSize).toDouble(),
textColor: settingsService.read(SettingsService.subtitleTextColor),
borderSize: settingsService.read(SettingsService.subtitleBorderSize).toDouble(),
borderColor: settingsService.read(SettingsService.subtitleBorderColor),
bgColor: settingsService.read(SettingsService.subtitleBackgroundColor),
bgOpacity: settingsService.read(SettingsService.subtitleBackgroundOpacity),
subtitlePosition: settingsService.read(SettingsService.subtitlePosition),
bold: settingsService.read(SettingsService.subtitleBold),
italic: settingsService.read(SettingsService.subtitleItalic),
);
}
_ExternalSubtitleOpenPlan _prepareExternalSubtitleOpenPlan({
required Player player,
required List<SubtitleTrack> externalSubtitles,
bool waitForFileLoaded = true,
}) {
final attachesAtOpen = player.attachesExternalSubtitlesAtOpen;
final hasExternalSubtitles = externalSubtitles.isNotEmpty;
return _ExternalSubtitleOpenPlan(
externalSubtitles: externalSubtitles,
attachesAtOpen: attachesAtOpen,
readyAfterOpen: waitForFileLoaded && !attachesAtOpen && hasExternalSubtitles
? player.streams.fileLoaded.first.timeout(
const Duration(seconds: 15),
onTimeout: () {
appLogger.w('Timed out waiting for file-loaded before adding external subtitles');
},
)
: null,
);
}
/// Build the per-item [TrackManager] for a freshly opened source. The
/// start and reload flows construct it identically apart from where the
/// preferred tracks and profile settings come from.
TrackManager _buildTrackManager({
required Player forPlayer,
required MediaItem metadata,
required PlexClient? plexClient,
required MediaServerUserProfile? Function() getProfileSettings,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
}) {
return TrackManager(
player: forPlayer,
isActive: () => mounted && player == forPlayer,
// Plex writes track changes immediately. Jellyfin persists selected
// indexes through playback progress reports.
persistTrackPreference: plexClient != null ? _plexTrackPersister(() => plexClient) : null,
getProfileSettings: getProfileSettings,
waitForProfileSettings: _waitForProfileSettingsIfNeeded,
metadata: metadata,
mediaInfo: _currentMediaInfo,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
showMessage: (message, {duration}) {
if (mounted) showAppSnackBar(context, message, duration: duration);
},
);
}
/// Apply track selection for a freshly opened source: backends that cannot
/// attach external subtitles during open use the post-open sub-add dance
/// (opened paused to avoid the issue #226 race), others arm selection
/// directly.
/// [shouldResumeAfterSubtitleLoad] lets a startup gate own the resume.
/// [applySelectionWhenResumeSkipped] is for flows that legitimately stay
/// paused (e.g. a transcode restart while paused): selection is still
/// armed and the waiting flag cleared instead of leaving both dangling.
Future<void> _applyTracksAfterOpen({
required TrackManager trackManager,
required _ExternalSubtitleOpenPlan externalSubtitlePlan,
required bool Function() shouldResumeAfterSubtitleLoad,
bool applySelectionWhenResumeSkipped = false,
}) async {
if (externalSubtitlePlan.requiresPostOpenAdd) {
trackManager.waitingForExternalSubsTrackSelection = true;
try {
await trackManager.addExternalSubtitles(
externalSubtitlePlan.externalSubtitles,
waitUntilReady: externalSubtitlePlan.readyAfterOpen,
);
} finally {
if (shouldResumeAfterSubtitleLoad()) {
_playbackIntentShouldPlay = true;
await trackManager.resumeAfterSubtitleLoad();
} else if (applySelectionWhenResumeSkipped) {
trackManager.waitingForExternalSubsTrackSelection = false;
trackManager.applyTrackSelectionWhenReady();
}
}
} else {
// Subs attached at open time (ExoPlayer) or none: apply once tracks
// are available.
trackManager.applyTrackSelectionWhenReady();
}
}
/// Drop the previous item's scrub-preview source and kick off the async
/// thumbnail load for the new one.
void _resetScrubPreviewForNewItem({
required MediaItem metadata,
required MediaSourceInfo? mediaInfo,
required MediaServerClient? mediaClient,
}) {
_scrubPreviewSource?.dispose();
_setPlayerState(() => _scrubPreviewSource = null);
_queueScrubPreviewLoad(metadata: metadata, mediaInfo: mediaInfo, mediaClient: mediaClient);
}
/// Per-open network stream tunings: ffmpeg auto-reconnect plus an enlarged
/// mpv stream ring buffer for poorly interleaved MP4/MOV direct play (the
/// ring absorbs the demuxer's audio↔video byte ping-pong so HTTP reads stay
/// linear instead of dropping the connection on every byte seek — see
/// [networkStreamRingBytes]). Both properties are always written, set or
/// reset, so a reused player never carries one item's tuning into the next
/// open. On Android with ExoPlayer active they are stashed natively and
/// replayed on the exo→mpv fallback, so keep them unconditional.
Future<void> _applyNetworkStreamTuning({
required Player player,
required bool isNetworkVod,
required bool isTranscoding,
required MediaVersion? selectedVersion,
}) async {
if (isNetworkVod) {
// Covers network drops up to 10 min; applies to transcode streams too.
//
// reconnect_on_http_error=503: without it, ffmpeg abandons a reconnect
// that gets an HTTP error and the truncated body surfaces as a clean
// mid-file EOF (#1520 — PMS answers 503 while restarting/maintenance).
// Deliberately 503 only: a persistent 500 must keep failing fast so the
// server-limit dialog (_server500Pattern) appears promptly, and a
// multi-code list would need mpv's %len% quoting to survive the
// comma-separated option string. While ffmpeg retries, mpv reports
// buffering, which also makes the server-online reconnect hook in
// _wirePlayerStreams reachable.
await player.setProperty(
'stream-lavf-o',
'reconnect=1,reconnect_on_network_error=1,reconnect_on_http_error=503,'
'reconnect_streamed=1,reconnect_delay_max=600',
);
} else {
await player.setProperty('stream-lavf-o', '');
}
int? ringBytes;
if (isNetworkVod && !isTranscoding) {
// Transcode (HLS) playback only uses the mpv stream layer for the
// playlist file; segment fetches happen inside ffmpeg's hls demuxer.
final maxBytes = Platform.isAndroid
? androidStreamRingCapBytes(await PlayerAndroid.getHeapSize())
: maxStreamRingBytes;
ringBytes = networkStreamRingBytes(
container: selectedVersion?.container,
bitrateKbps: selectedVersion?.bitrate,
maxBytes: maxBytes,
);
}
if (ringBytes != null) {
appLogger.i(
'Stream ring buffer: ${ringBytes ~/ (1024 * 1024)}MiB '
'(container=${selectedVersion?.container}, bitrate=${selectedVersion?.bitrate}kbps)',
);
} else {
appLogger.d(
'Stream ring buffer: default '
'(networkVod=$isNetworkVod, transcoding=$isTranscoding, container=${selectedVersion?.container})',
);
}
await player.setProperty('stream-buffer-size', '${ringBytes ?? mpvDefaultStreamBufferBytes}');
}
/// Open [videoUrl] on [player]: stream tuning → open → native subtitle style.
///
/// [shouldContinue] is re-checked between the awaits so stale generations
/// stop without touching the player further. [onOpened] fires immediately
/// after open() returns (before styling) so callers can flip rollback
/// bookkeeping at the exact ownership boundary.
///
/// Returns whether open was issued and whether a selected remote sidecar
/// stalled after the primary media was ready, requiring an automatic reopen
/// without subtitles.
Future<_MediaOpenResult> _openMediaOnPlayer({
required Player player,
required SettingsService settingsService,
required String videoUrl,
required bool isTranscoding,
required bool isLocalMedia,
required MediaVersion? selectedVersion,
required _PlaybackOpenTiming timing,
Map<String, String>? headers,
required bool play,
List<SubtitleTrack>? externalSubtitlesAtOpen,
bool Function()? shouldContinue,
void Function()? onOpening,
void Function()? onOpened,
void Function(bool available)? onMediaAvailabilityChanged,
}) async {
await _applyNetworkStreamTuning(
player: player,
isNetworkVod: !isLocalMedia && !widget.isLive,
isTranscoding: isTranscoding,
selectedVersion: selectedVersion,
);
if (shouldContinue != null && !shouldContinue()) return const _MediaOpenResult(didOpen: false);
final media = Media(videoUrl, start: timing.mediaStart, headers: headers);
final sidecarOpenGuard = MpvSidecarOpenGuard.armIfNeeded(player: player, subtitles: externalSubtitlesAtOpen);
Future<void> openMedia({required bool shouldPlay, List<SubtitleTrack>? externalSubtitles}) {
onOpening?.call();
return player.open(
media,
play: shouldPlay,
externalSubtitles: externalSubtitles,
timelineDuration: timing.timelineDuration,
);
}
try {
await openMedia(shouldPlay: play, externalSubtitles: externalSubtitlesAtOpen);
onOpened?.call();
onMediaAvailabilityChanged?.call(true);
} catch (_) {
await sidecarOpenGuard?.dispose();
rethrow;
}
var sidecarFallbackUsed = false;
final sidecarOutcome = await sidecarOpenGuard?.wait();
if (sidecarOutcome == MpvSidecarOpenOutcome.aborted) {
return const _MediaOpenResult(didOpen: true);
}
if (sidecarOutcome == MpvSidecarOpenOutcome.stalled) {
appLogger.w('Selected subtitle sidecar stalled after primary media discovery; reopening without subtitles');
if (shouldContinue != null && !shouldContinue()) {
return const _MediaOpenResult(didOpen: true);
}
await player.stop();
onMediaAvailabilityChanged?.call(false);
if (shouldContinue != null && !shouldContinue()) return const _MediaOpenResult(didOpen: true);
// Respect a pause requested while mpv was waiting on the sidecar. A
// startup gate encoded by [play] remains authoritative when it is false.
await openMedia(shouldPlay: play && _playbackIntentShouldPlay);
onMediaAvailabilityChanged?.call(true);
sidecarFallbackUsed = true;
if (mounted && (shouldContinue == null || shouldContinue())) {
showErrorSnackBar(context, t.videoControls.subtitleUnavailableFallback);
}
}
if (shouldContinue != null && !shouldContinue()) {
return _MediaOpenResult(didOpen: true, sidecarFallbackUsed: sidecarFallbackUsed);
}
await _applyNativeSubtitleStyle(player, settingsService);
return _MediaOpenResult(didOpen: true, sidecarFallbackUsed: sidecarFallbackUsed);
}
}