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

359 lines
15 KiB
Dart

part of '../../video_player_screen.dart';
extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
Future<void> _startPlayback() async {
final currentPlayer = player;
if (!mounted || currentPlayer == null) return;
final attempt = _beginPlaybackAttempt(currentPlayer);
// Live TV mode: bypass standard playback initialization
if (widget.isLive) {
try {
_hasFirstFrame.value = false;
await currentPlayer.requestAudioFocus();
await _setLiveStreamOptions(currentPlayer);
if (!attempt.isCurrent) return;
// Start the session inside the player for both backends (loading
// spinner covers Plex's tune / Jellyfin's stream negotiation).
final channel = widget.live!.channel;
final session = await _startLiveSession(channel);
if (session == null) throw Exception('Failed to start live channel');
if (!mounted || !attempt.isCurrent) {
_abandonLiveSession(session);
return;
}
_live.adoptSession(session);
// 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;
final captureBuffer = session.captureBuffer;
final programBeginsAt = session.program.beginsAt;
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 streamUrl = await session.streamUrlAt(offsetSeconds: offsetSeconds);
if (streamUrl == null || !mounted) throw Exception('Failed to build stream path');
// Track stream start epoch for position calculations
if (offsetSeconds != null) {
_live.streamStartEpoch = captureBuffer!.startedAt + offsetSeconds;
_live.atLiveEdge = false;
_live.playbackStartTime = DateTime.now();
} else {
_live.markStreamRestartedAtLiveEdge();
}
await currentPlayer.setProperty('force-seekable', 'no');
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
if (!attempt.isCurrent) return;
_trackManager?.cacheExternalSubtitles(const []);
await _initVideoFilterAndPip();
if (!mounted || player != currentPlayer) return;
if (mounted) {
// Live TV never commits a PlaybackSession, so the session-derived
// versions/mediaInfo getters already read empty here.
_setPlayerState(() {
_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 {
PlaybackContext playbackContext;
if (_offlineLibraryMode) {
final playbackResolver = PlaybackSourceResolver(
serverManager: context.read<MultiServerProvider>().serverManager,
database: context.read<AppDatabase>(),
);
playbackContext = await playbackResolver.resolve(
metadata: _currentMetadata,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: _requestedMediaSourceId,
offlineLibraryMode: true,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
);
if (playbackContext.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.
final playbackDataFuture = _playbackDataFuture;
if (playbackDataFuture == null) {
throw StateError('Playback data was not prepared before playback start');
}
playbackContext = await playbackDataFuture;
if (!mounted || player != currentPlayer) return;
if (playbackContext.result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
if (mounted) {
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
}
}
}
final result = playbackContext.result;
final streamHeaders = playbackContext.streamHeaders;
// 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).
_commitPlaybackSession(
PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: _selectedQualityPreset,
requestedMediaSourceId: _requestedMediaSourceId,
),
);
// Primary refresh-rate path: when metadata provides FPS, Android players
// can switch before creating decoders. MPV still needs a startup refresh
// when MediaCodec has already produced its first paused frame.
final settingsService = await SettingsService.getInstance();
if (!attempt.isCurrent) return;
final displayCriteria = result.mediaInfo?.displayCriteria;
final attachesSubsAtOpen = currentPlayer.attachesExternalSubtitlesAtOpen;
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
var audioFocusReady = false;
Future<void> ensureAudioFocus() async {
if (audioFocusReady) return;
final focusFuture = _audioFocusFuture;
if (focusFuture != null) {
await focusFuture;
_audioFocusFuture = null;
} else {
await currentPlayer.requestAudioFocus();
}
audioFocusReady = true;
}
final frameRatePlan = await _prepareFrameRateForOpen(
currentPlayer: currentPlayer,
settingsService: settingsService,
preKnownFps: displayCriteria?.fps,
hasVideoUrl: result.videoUrl != null,
ensureAudioFocus: ensureAudioFocus,
);
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;
// 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,
);
final shouldAutoPlay = !shouldHoldPlaybackStart && !wtOwnsStart && (attachesSubsAtOpen || !hasExternalSubs);
frameRatePlan.armStartupRefreshGate(currentPlayer);
// 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.
final openTiming = _playbackOpenTiming(
backend: _currentMetadata.backend,
isTranscoding: result.isTranscoding,
resumePosition: resumePosition,
durationMs: _currentMetadata.durationMs,
);
final didOpen = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding,
isLocalMedia: _isOfflinePlayback,
selectedVersion: result.selectedVersion,
timing: openTiming,
headers: streamHeaders,
play: shouldAutoPlay,
externalSubtitlesAtOpen: attachesSubsAtOpen && hasExternalSubs ? result.externalSubtitles : null,
shouldContinue: () => attempt.isCurrent,
);
if (!didOpen || !attempt.isCurrent) return;
// 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();
}
}
// 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;
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 (!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,
preferredSubtitleTrack: _preferredSubtitleTrack,
preferredSecondarySubtitleTrack: _preferredSecondarySubtitleTrack,
);
// Store external subtitles for re-use after backend fallback
_trackManager!.cacheExternalSubtitles(result.externalSubtitles);
await _applyTracksAfterOpen(
forPlayer: currentPlayer,
trackManager: _trackManager!,
externalSubtitles: result.externalSubtitles,
// When a startup gate below owns the resume, skip this one to
// avoid a double-play. Watch Together stays paused for the group
// start, so selection is armed through the resume-skipped branch.
shouldResumeAfterSubtitleLoad: () =>
!shouldHoldPlaybackStart && !wtOwnsStart && mounted && player == currentPlayer,
applySelectionWhenResumeSkipped: wtOwnsStart && !shouldHoldPlaybackStart,
);
await _releaseFrameRateStartupGate(
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether(
currentPlayer: currentPlayer,
attachesSubsAtOpen: attachesSubsAtOpen,
hasExternalSubs: hasExternalSubs,
reason: reason,
wtOwnsStart: wtOwnsStart,
wtStartupHold: wtStartupHold,
),
);
// 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();
}
}
} 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()));
}
}
}
}