529 lines
24 KiB
Dart
529 lines
24 KiB
Dart
part of '../../video_player_screen.dart';
|
|
|
|
extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
|
Future<void> _startPlayback() async {
|
|
final currentPlayer = player;
|
|
if (!mounted || currentPlayer == null) return;
|
|
|
|
// Live TV mode: bypass standard playback initialization
|
|
if (widget.isLive) {
|
|
try {
|
|
_hasFirstFrame.value = false;
|
|
await currentPlayer.requestAudioFocus();
|
|
await _setLiveStreamOptions();
|
|
if (!mounted || player != currentPlayer) return;
|
|
|
|
String streamUrl;
|
|
if (_liveStreamUrl != null) {
|
|
streamUrl = _liveStreamUrl!;
|
|
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
|
|
_isAtLiveEdge = true;
|
|
} else {
|
|
// Tune channel inside the player (shows loading spinner while tuning)
|
|
final channels = widget.liveChannels;
|
|
final channelIndex = _liveChannelIndex;
|
|
if (channels == null || channelIndex < 0 || channelIndex >= channels.length) {
|
|
throw Exception('No channel to tune');
|
|
}
|
|
final channel = channels[channelIndex];
|
|
appLogger.d('Tune: dvrKey=$_liveDvrKey channelKey=${channel.key}');
|
|
final client = _liveClient;
|
|
if (client is! PlexClient) {
|
|
throw StateError(
|
|
'In-player live tuning is Plex-only; got ${client?.runtimeType ?? 'null'}. '
|
|
'Jellyfin live TV must pass a pre-resolved liveStreamUrl via LiveTvSupport.resolveStreamUrl.',
|
|
);
|
|
}
|
|
final dvrKey = _liveDvrKey;
|
|
if (dvrKey == null) throw Exception('No DVR to tune');
|
|
final tuneResult = await client.tuneChannel(dvrKey, channel.key);
|
|
if (tuneResult == null) throw Exception('Failed to tune channel');
|
|
|
|
_liveSessionIdentifier = tuneResult.sessionIdentifier;
|
|
_liveSessionPath = tuneResult.sessionPath;
|
|
_liveProgramId = tuneResult.metadata.ratingKey;
|
|
_liveDurationMs = tuneResult.metadata.duration;
|
|
_captureBuffer = tuneResult.captureBuffer;
|
|
_programBeginsAt = tuneResult.beginsAt;
|
|
_transcodeSessionId = generateSessionIdentifier();
|
|
|
|
// Show "Watch from Start" dialog when an existing capture session has >60s of history.
|
|
// On a fresh tune (no active recording), the buffer is empty so this won't trigger.
|
|
int? offsetSeconds;
|
|
if (_captureBuffer != null && _programBeginsAt != null) {
|
|
final nowEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
final offsetProgramStart = _programBeginsAt! - _captureBuffer!.startedAt.round();
|
|
// If a session recording started after current program start, offset of program start at will be negative.
|
|
// If a session recording started before current program start, offset of program start will be positive.
|
|
// If guide data is not available, program start will be equal to current time.
|
|
final useProgramStart = offsetProgramStart > 0 && nowEpoch - _programBeginsAt! > 60;
|
|
final effectiveStart = useProgramStart ? _programBeginsAt! : _captureBuffer!.seekableStartEpoch;
|
|
final elapsed = nowEpoch - effectiveStart;
|
|
appLogger.d(
|
|
'Time-shift: buffer=${_captureBuffer!.seekableDurationSeconds}s, '
|
|
'beginsAt=$_programBeginsAt, elapsed=${elapsed}s (need >60 for dialog)',
|
|
);
|
|
if (elapsed > 60) {
|
|
final watchFromStart = await _showWatchFromStartDialog(effectiveStart, nowEpoch);
|
|
if (!mounted) return;
|
|
if (watchFromStart == true) {
|
|
offsetSeconds = useProgramStart ? offsetProgramStart : _captureBuffer!.seekStartSeconds.round();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build the stream URL (with optional offset for time-shift)
|
|
final streamPath = await client.buildLiveStreamPath(
|
|
sessionPath: tuneResult.sessionPath,
|
|
sessionIdentifier: tuneResult.sessionIdentifier,
|
|
transcodeSessionId: _transcodeSessionId!,
|
|
offsetSeconds: offsetSeconds,
|
|
);
|
|
if (streamPath == null || !mounted) throw Exception('Failed to build stream path');
|
|
|
|
streamUrl = client.buildLiveStreamUrl(streamPath);
|
|
_liveStreamUrl = streamUrl;
|
|
|
|
// Track stream start epoch for position calculations
|
|
if (offsetSeconds != null) {
|
|
_streamStartEpoch = _captureBuffer!.startedAt + offsetSeconds;
|
|
_isAtLiveEdge = false;
|
|
} else {
|
|
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
|
|
_isAtLiveEdge = true;
|
|
}
|
|
}
|
|
|
|
_livePlaybackStartTime = DateTime.now();
|
|
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
|
|
if (!mounted || player != currentPlayer) return;
|
|
|
|
_trackManager?.cacheExternalSubtitles(const []);
|
|
|
|
await _initVideoFilterAndPip();
|
|
if (!mounted || player != currentPlayer) return;
|
|
|
|
if (mounted) {
|
|
_setPlayerState(() {
|
|
_availableVersions = [];
|
|
_currentMediaInfo = null;
|
|
_isPlayerInitialized = true;
|
|
});
|
|
_trackManager?.mediaInfo = null;
|
|
}
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to start live TV playback', error: e, stackTrace: st);
|
|
unawaited(_sendLiveTimeline('stopped'));
|
|
if (mounted) {
|
|
showErrorSnackBar(context, e.toString());
|
|
unawaited(_handleBackButton());
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Capture providers before async gaps
|
|
final offlineWatchService = context.read<OfflineWatchSyncService>();
|
|
|
|
try {
|
|
PlaybackInitializationResult result;
|
|
Map<String, String>? streamHeaders;
|
|
|
|
if (widget.isOffline) {
|
|
// Offline mode: route through PlaybackInitializationService with a
|
|
// (possibly null) cached client. The service reads cached media
|
|
// info via the client when available, falls back to local file +
|
|
// sidecar subtitles otherwise.
|
|
final cachedSourceClient = _getMediaServerClient(context);
|
|
final offlineService = PlaybackInitializationService(
|
|
client: cachedSourceClient,
|
|
database: context.read<AppDatabase>(),
|
|
);
|
|
result = await offlineService.getPlaybackData(
|
|
metadata: _currentMetadata,
|
|
selectedMediaIndex: widget.selectedMediaIndex,
|
|
preferOffline: true,
|
|
);
|
|
if (result.videoUrl == null) {
|
|
throw PlaybackException(t.messages.fileInfoNotAvailable);
|
|
}
|
|
} else {
|
|
// Online path: `_playbackDataFuture` was kicked off in `_initializePlayer`
|
|
// in parallel with MPV setup. Quality preset + server capabilities +
|
|
// headers were resolved there too. Just await the result.
|
|
streamHeaders = _streamHeaders;
|
|
final playbackDataFuture = _playbackDataFuture;
|
|
if (playbackDataFuture == null) {
|
|
throw StateError('Playback data was not prepared before playback start');
|
|
}
|
|
result = await playbackDataFuture;
|
|
if (!mounted || player != currentPlayer) return;
|
|
|
|
_isTranscoding = result.isTranscoding;
|
|
_effectiveIsOffline = result.isOffline;
|
|
_playbackPlaySessionId = result.playSessionId;
|
|
_playbackPlayMethod = result.playMethod;
|
|
if (result.activeAudioStreamId != null) {
|
|
_selectedAudioStreamId = result.activeAudioStreamId;
|
|
}
|
|
|
|
if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
|
|
if (mounted) {
|
|
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
|
|
}
|
|
// Reset the preset so the UI reflects what's actually playing.
|
|
_selectedQualityPreset = TranscodeQualityPreset.original;
|
|
}
|
|
}
|
|
|
|
// Primary refresh-rate path: when metadata provides FPS, Android MPV can
|
|
// switch before `loadfile`; ExoPlayer and MPV fallback cases still open
|
|
// paused and switch before visible playback starts.
|
|
final settingsService = await SettingsService.getInstance();
|
|
if (!mounted || player != currentPlayer) return;
|
|
final preKnownFps = result.mediaInfo?.frameRate;
|
|
final willAutoSwitch =
|
|
Platform.isAndroid &&
|
|
settingsService.read(SettingsService.matchContentFrameRate) &&
|
|
preKnownFps != null &&
|
|
preKnownFps > 0;
|
|
final isExoPlayer = currentPlayer is PlayerAndroid;
|
|
final isAndroidMpv = Platform.isAndroid && !isExoPlayer;
|
|
final needsAndroidMpvFrameRateStartup = willAutoSwitch && isAndroidMpv && result.videoUrl != null;
|
|
var didPreLoadFrameRateSwitch = false;
|
|
var needsPostOpenFrameRateSwitch = willAutoSwitch && !needsAndroidMpvFrameRateStartup;
|
|
var needsAndroidMpvStartupRefresh = needsAndroidMpvFrameRateStartup;
|
|
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
|
|
Future<bool>? androidMpvStartupReady;
|
|
|
|
// 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 (needsAndroidMpvFrameRateStartup) {
|
|
final delaySec = settingsService.read(SettingsService.displaySwitchDelay);
|
|
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
|
|
_suppressMediaPauseDuringFrameRateSwitch = true;
|
|
Future.delayed(Duration(seconds: 2 + delaySec + 1), () {
|
|
_suppressMediaPauseDuringFrameRateSwitch = false;
|
|
});
|
|
try {
|
|
appLogger.d(
|
|
'Frame rate matching: pre-load MPV switch to ${preKnownFps}fps '
|
|
'(duration: ${durationMs}ms, delay=${delaySec}s)',
|
|
);
|
|
didPreLoadFrameRateSwitch = await currentPlayer.setVideoFrameRate(
|
|
preKnownFps,
|
|
durationMs,
|
|
extraDelayMs: delaySec * 1000,
|
|
);
|
|
if (!mounted || player != currentPlayer) return;
|
|
if (didPreLoadFrameRateSwitch) {
|
|
_frameRateMatchingApplied = true;
|
|
}
|
|
appLogger.d(
|
|
'Frame rate matching: pre-load MPV switch complete '
|
|
'(switched=$didPreLoadFrameRateSwitch, delay=${delaySec}s, '
|
|
'startupRefresh=$needsAndroidMpvStartupRefresh)',
|
|
);
|
|
} catch (e) {
|
|
appLogger.w('Failed to apply pre-load MPV frame rate matching', error: e);
|
|
needsPostOpenFrameRateSwitch = true;
|
|
needsAndroidMpvStartupRefresh = false;
|
|
}
|
|
}
|
|
|
|
final shouldHoldPlaybackStart = needsPostOpenFrameRateSwitch || needsAndroidMpvStartupRefresh;
|
|
Duration? resumePosition;
|
|
|
|
// Open video through Player
|
|
if (result.videoUrl != null) {
|
|
// Reset first frame flag and frame rate retry counter for new video
|
|
_hasFirstFrame.value = false;
|
|
_frameRateRetries = 0;
|
|
_frameRateMatchingApplied = false;
|
|
if (didPreLoadFrameRateSwitch || needsAndroidMpvFrameRateStartup) {
|
|
_frameRateMatchingApplied = 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).
|
|
if (_audioFocusFuture != null) {
|
|
await _audioFocusFuture;
|
|
_audioFocusFuture = null;
|
|
} else {
|
|
await currentPlayer.requestAudioFocus();
|
|
}
|
|
if (!mounted || player != currentPlayer) return;
|
|
|
|
// Pass resume position if available.
|
|
// In offline mode, prefer locally tracked progress over the cached server value
|
|
// since the user may have watched further since downloading.
|
|
if (_isOfflinePlayback) {
|
|
final globalKey = _currentMetadata.globalKey;
|
|
final localOffset = await offlineWatchService.getLocalViewOffset(globalKey);
|
|
if (!mounted || player != currentPlayer) return;
|
|
if (localOffset != null && localOffset > 0) {
|
|
resumePosition = Duration(milliseconds: localOffset);
|
|
appLogger.d('Resuming offline playback from local progress: ${localOffset}ms');
|
|
}
|
|
}
|
|
resumePosition ??= _currentMetadata.viewOffsetMs != null
|
|
? Duration(milliseconds: _currentMetadata.viewOffsetMs!)
|
|
: null;
|
|
|
|
// Enable FFmpeg auto-reconnect for VOD streams (covers network drops
|
|
// up to 10 min). Forwarded to the Kotlin layer on Android so MPV
|
|
// inherits it on the ExoPlayer→MPV fallback path (see
|
|
// _onBackendSwitched), so keep it unconditional.
|
|
if (!_isOfflinePlayback && !widget.isLive) {
|
|
await currentPlayer.setProperty(
|
|
'stream-lavf-o',
|
|
'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600',
|
|
);
|
|
}
|
|
|
|
await currentPlayer.setDisplayCriteria(result.isTranscoding ? null : result.mediaInfo?.displayCriteria);
|
|
|
|
final shouldAutoPlay = !shouldHoldPlaybackStart && (isExoPlayer || !hasExternalSubs);
|
|
if (needsAndroidMpvStartupRefresh) {
|
|
appLogger.d('Frame rate matching: opening Android MPV paused for startup buffer flush');
|
|
androidMpvStartupReady = currentPlayer.streams.playbackRestart.first
|
|
.then((_) => true)
|
|
.timeout(
|
|
const Duration(seconds: 15),
|
|
onTimeout: () {
|
|
appLogger.w('Timed out waiting for Android MPV startup frame before buffer flush');
|
|
return false;
|
|
},
|
|
);
|
|
}
|
|
|
|
// ExoPlayer: attach external subs at open time so it discovers
|
|
// them in a single prepare() — no media reload needed for selection.
|
|
// MPV (all platforms including Android): external subs added after open via sub-add.
|
|
await currentPlayer.open(
|
|
Media(result.videoUrl!, start: resumePosition, headers: streamHeaders),
|
|
play: shouldAutoPlay,
|
|
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
|
|
);
|
|
if (!mounted || player != currentPlayer) return;
|
|
|
|
// Apply subtitle styling to ExoPlayer native layer (CaptionStyleCompat + libass font scale)
|
|
// Must be called after open() since that's when ExoPlayer initializes
|
|
if (currentPlayer is PlayerAndroid) {
|
|
await currentPlayer.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),
|
|
);
|
|
}
|
|
|
|
// Attach player to Watch Together session for sync (if in session)
|
|
if (mounted && !_isOfflinePlayback) {
|
|
_attachToWatchTogetherSession();
|
|
_notifyWatchTogetherMediaChange();
|
|
}
|
|
}
|
|
|
|
// Update available versions from the playback data
|
|
if (mounted) {
|
|
_setPlayerState(() {
|
|
_availableVersions = result.availableVersions;
|
|
_currentMediaInfo = result.mediaInfo;
|
|
_scrubPreviewSource?.dispose();
|
|
_scrubPreviewSource = null;
|
|
});
|
|
|
|
// Backend-neutral scrub-thumbnail load. The factory dispatches to
|
|
// BIF (Plex) or trickplay sprite sheets (Jellyfin) and returns null
|
|
// when the inputs aren't sufficient. Guard against media-change
|
|
// races during the async load.
|
|
final mediaClient = context.tryGetMediaClientForServer(_currentMetadata.serverId);
|
|
final mediaInfoAtStart = _currentMediaInfo;
|
|
if (mediaInfoAtStart != null && !_isOfflinePlayback && mediaClient != null) {
|
|
unawaited(
|
|
mediaClient
|
|
.createScrubPreviewSource(item: _currentMetadata, mediaSource: mediaInfoAtStart)
|
|
.then((service) {
|
|
if (service == null) return;
|
|
if (mounted && identical(_currentMediaInfo, mediaInfoAtStart)) {
|
|
_setPlayerState(() => _scrubPreviewSource = service);
|
|
} else {
|
|
service.dispose();
|
|
}
|
|
})
|
|
.catchError((e, st) {
|
|
appLogger.w('Scrub preview load failed', error: e, stackTrace: st);
|
|
}),
|
|
);
|
|
}
|
|
|
|
await _initVideoFilterAndPip();
|
|
if (!mounted || player != currentPlayer) return;
|
|
|
|
if (player == currentPlayer) {
|
|
// Auto-PiP: set up callback for API 26-30 path and initial state
|
|
if (_autoPipEnabled) {
|
|
PipService.onAutoPipEntering = () {
|
|
_setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering');
|
|
_preparePipFiltersForEntry();
|
|
};
|
|
final pipManager = _videoPIPManager;
|
|
if (currentPlayer.state.playing && pipManager != null) {
|
|
unawaited(pipManager.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 (!mounted || player != currentPlayer) return;
|
|
|
|
// Track manager: owns track selection, external subtitle loading, and Plex
|
|
// immediate stream writes. Jellyfin persists selected stream indexes through
|
|
// playback progress reports instead.
|
|
final plexTrackClient = mediaClient is PlexClient ? mediaClient : null;
|
|
_trackManager = TrackManager(
|
|
player: currentPlayer,
|
|
isActive: () => mounted && player == currentPlayer,
|
|
persistTrackPreference: plexTrackClient != null ? _plexTrackPersister(() => plexTrackClient) : null,
|
|
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
|
|
waitForProfileSettings: _waitForProfileSettingsIfNeeded,
|
|
metadata: _currentMetadata,
|
|
mediaInfo: _currentMediaInfo,
|
|
preferredAudioTrack: widget.preferredAudioTrack,
|
|
preferredSubtitleTrack: widget.preferredSubtitleTrack,
|
|
preferredSecondarySubtitleTrack: widget.preferredSecondarySubtitleTrack,
|
|
showMessage: (message, {duration}) {
|
|
if (mounted) showAppSnackBar(context, message, duration: duration);
|
|
},
|
|
);
|
|
|
|
// Store external subtitles for re-use after backend fallback
|
|
_trackManager!.cacheExternalSubtitles(result.externalSubtitles);
|
|
|
|
Future<void> resumeAfterStartupGate(String reason) async {
|
|
if (!mounted || player != currentPlayer) return;
|
|
final trackManager = _trackManager;
|
|
if (trackManager == null) return;
|
|
appLogger.d('Frame rate matching: resuming playback after $reason');
|
|
if (currentPlayer is! PlayerAndroid && hasExternalSubs) {
|
|
await trackManager.resumeAfterSubtitleLoad();
|
|
} else {
|
|
await currentPlayer.play();
|
|
}
|
|
}
|
|
|
|
// MPV with external subs: add after open via sub-add,
|
|
// opened paused to avoid race condition (issue #226)
|
|
if (currentPlayer is! PlayerAndroid && result.externalSubtitles.isNotEmpty) {
|
|
_hasFirstFrame.value = false;
|
|
_trackManager!.waitingForExternalSubsTrackSelection = true;
|
|
|
|
try {
|
|
await _trackManager!.addExternalSubtitles(result.externalSubtitles);
|
|
} finally {
|
|
// When a startup gate below owns the resume,
|
|
// skip this one to avoid a double-play.
|
|
if (!shouldHoldPlaybackStart && mounted && player == currentPlayer) {
|
|
await _trackManager!.resumeAfterSubtitleLoad();
|
|
}
|
|
}
|
|
} else {
|
|
// Android (subs attached at open time) or no external subs:
|
|
// apply once tracks are available
|
|
_trackManager!.applyTrackSelectionWhenReady();
|
|
}
|
|
|
|
// 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 (needsPostOpenFrameRateSwitch && mounted && player == currentPlayer) {
|
|
_frameRateMatchingApplied = true;
|
|
final delaySec = settingsService.read(SettingsService.displaySwitchDelay);
|
|
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
|
|
_suppressMediaPauseDuringFrameRateSwitch = true;
|
|
Future.delayed(Duration(seconds: 2 + delaySec + 1), () {
|
|
_suppressMediaPauseDuringFrameRateSwitch = false;
|
|
});
|
|
bool didSwitch = false;
|
|
try {
|
|
didSwitch = await currentPlayer.setVideoFrameRate(preKnownFps!, durationMs, extraDelayMs: delaySec * 1000);
|
|
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 resumeAfterStartupGate('post-open frame rate switch');
|
|
|
|
unawaited(
|
|
Sentry.addBreadcrumb(
|
|
Breadcrumb(
|
|
message: 'Pre-playback frame rate: ${preKnownFps}fps, switched=$didSwitch, delay=${delaySec}s',
|
|
category: 'player',
|
|
),
|
|
),
|
|
);
|
|
} else if (needsAndroidMpvStartupRefresh && mounted && player == currentPlayer) {
|
|
appLogger.d('Frame rate matching: waiting for Android MPV startup frame before buffer flush');
|
|
final startupReady = androidMpvStartupReady == null ? false : await androidMpvStartupReady;
|
|
if (mounted && player == currentPlayer) {
|
|
if (startupReady) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'pre-load frame rate startup');
|
|
await resumeAfterStartupGate('startup buffer flush');
|
|
} else {
|
|
appLogger.w('Frame rate matching: skipping Android MPV buffer flush because startup frame timed out');
|
|
await resumeAfterStartupGate('startup frame timeout');
|
|
}
|
|
}
|
|
|
|
unawaited(
|
|
Sentry.addBreadcrumb(
|
|
Breadcrumb(
|
|
message: 'Android MPV startup buffer flush after pre-load frame-rate switch',
|
|
category: 'player',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} on PlaybackException catch (e, st) {
|
|
appLogger.w('Playback initialization failed', error: e, stackTrace: st);
|
|
if (mounted) {
|
|
_hasFirstFrame.value = true; // Hide spinner on error
|
|
showErrorSnackBar(context, e.message);
|
|
}
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to start playback', error: e, stackTrace: st);
|
|
if (mounted) {
|
|
_hasFirstFrame.value = true; // Hide spinner on error
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
}
|