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

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()));
}
}
}
}