fix(player): harden initialization lifecycle
This commit is contained in:
@@ -4,20 +4,29 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState {
|
||||
/// Initialize VideoFilterManager and VideoPIPManager if not already set up.
|
||||
/// Called from both live TV and VOD playback paths.
|
||||
Future<void> _initVideoFilterAndPip() async {
|
||||
if (player == null || _videoFilterManager != null) return;
|
||||
final settings = await SettingsService.getInstance();
|
||||
_videoFilterManager = VideoFilterManager(
|
||||
player: player!,
|
||||
availableVersions: _availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
initialBoxFitMode: settings.read(SettingsService.defaultBoxFitMode),
|
||||
onBoxFitModeChanged: (mode) => settings.write(SettingsService.defaultBoxFitMode, mode),
|
||||
);
|
||||
_videoFilterManager!.updateVideoFilter();
|
||||
final currentPlayer = player;
|
||||
if (currentPlayer == null || (_videoFilterManager != null && _videoPIPManager != null)) return;
|
||||
|
||||
_videoPIPManager = VideoPIPManager(player: player!);
|
||||
_videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry;
|
||||
_videoPIPManager!.isPipActive.addListener(_onPipStateChanged);
|
||||
final needsVideoFilter = _videoFilterManager == null;
|
||||
final settings = needsVideoFilter ? await SettingsService.getInstance() : null;
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
|
||||
if (needsVideoFilter && _videoFilterManager == null && settings != null) {
|
||||
_videoFilterManager = VideoFilterManager(
|
||||
player: currentPlayer,
|
||||
availableVersions: _availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
initialBoxFitMode: settings.read(SettingsService.defaultBoxFitMode),
|
||||
onBoxFitModeChanged: (mode) => settings.write(SettingsService.defaultBoxFitMode, mode),
|
||||
);
|
||||
_videoFilterManager!.updateVideoFilter();
|
||||
}
|
||||
|
||||
if (_videoPIPManager == null) {
|
||||
_videoPIPManager = VideoPIPManager(player: currentPlayer);
|
||||
_videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry;
|
||||
_videoPIPManager!.isPipActive.addListener(_onPipStateChanged);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePIPMode() async {
|
||||
|
||||
@@ -18,7 +18,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
String? playMethod,
|
||||
MediaSourceInfo? mediaInfo,
|
||||
}) {
|
||||
if (player == null) return;
|
||||
final currentPlayer = player;
|
||||
if (currentPlayer == null) return;
|
||||
_stoppedProgressFuture = null;
|
||||
|
||||
// Progress tracker — offline mode queues for later sync; online mode
|
||||
@@ -27,7 +28,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
_progressTracker = PlaybackProgressTracker(
|
||||
client: null,
|
||||
metadata: metadata,
|
||||
player: player!,
|
||||
player: currentPlayer,
|
||||
isOffline: true,
|
||||
offlineWatchService: offlineWatchService,
|
||||
);
|
||||
@@ -36,7 +37,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
_progressTracker = PlaybackProgressTracker(
|
||||
client: mediaClient,
|
||||
metadata: metadata,
|
||||
player: player!,
|
||||
player: currentPlayer,
|
||||
playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'),
|
||||
playSessionId: playSessionId,
|
||||
mediaInfo: mediaInfo,
|
||||
@@ -68,7 +69,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
|
||||
/// Initialize the service layer
|
||||
Future<void> _initializeServices() async {
|
||||
if (!mounted || player == null) return;
|
||||
final currentPlayer = player;
|
||||
if (!mounted || currentPlayer == null) return;
|
||||
|
||||
// Live TV: send timeline heartbeats to keep transcode session alive
|
||||
if (widget.isLive) {
|
||||
@@ -84,14 +86,15 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
|
||||
// Initialize media controls manager (must exist before the per-item
|
||||
// helper wires its metadata update).
|
||||
_mediaControlsManager = MediaControlsManager();
|
||||
final mediaControlsManager = MediaControlsManager();
|
||||
_mediaControlsManager = mediaControlsManager;
|
||||
|
||||
// Set up media control event handling
|
||||
_mediaControlSubscription = _mediaControlsManager!.controlEvents.listen((event) {
|
||||
final currentPlayer = player;
|
||||
_mediaControlSubscription = mediaControlsManager.controlEvents.listen((event) {
|
||||
final activePlayer = player;
|
||||
if (_mediaControlsSuspendedForTvBackground) {
|
||||
final eventLabel = event.runtimeType.toString();
|
||||
if (currentPlayer != null && (event is PlayEvent || event is TogglePlayPauseEvent)) {
|
||||
if (activePlayer != null && (event is PlayEvent || event is TogglePlayPauseEvent)) {
|
||||
appLogger.d('Media control: $eventLabel received while Android TV background-suspended');
|
||||
unawaited(_requestForegroundResumeFromSuspendedMediaControl(eventLabel));
|
||||
} else {
|
||||
@@ -100,12 +103,12 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return;
|
||||
if (activePlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return;
|
||||
|
||||
if (event is PlayEvent) {
|
||||
appLogger.d('Media control: Play event received');
|
||||
_seekBackForRewind(currentPlayer!);
|
||||
currentPlayer.play();
|
||||
_seekBackForRewind(activePlayer!);
|
||||
activePlayer.play();
|
||||
_wasPlayingBeforeInactive = false;
|
||||
_updateMediaControlsPlaybackState();
|
||||
} else if (event is PauseEvent) {
|
||||
@@ -114,21 +117,21 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
return;
|
||||
}
|
||||
appLogger.d('Media control: Pause event received');
|
||||
currentPlayer!.pause();
|
||||
activePlayer!.pause();
|
||||
_updateMediaControlsPlaybackState();
|
||||
} else if (event is TogglePlayPauseEvent) {
|
||||
appLogger.d('Media control: Toggle play/pause event received');
|
||||
if (currentPlayer!.state.isActive) {
|
||||
currentPlayer.pause();
|
||||
if (activePlayer!.state.isActive) {
|
||||
activePlayer.pause();
|
||||
} else {
|
||||
_seekBackForRewind(currentPlayer);
|
||||
currentPlayer.play();
|
||||
_seekBackForRewind(activePlayer);
|
||||
activePlayer.play();
|
||||
_wasPlayingBeforeInactive = false;
|
||||
}
|
||||
_updateMediaControlsPlaybackState();
|
||||
} else if (event is SeekEvent) {
|
||||
appLogger.d('Media control: Seek event received to ${event.position}');
|
||||
unawaited(currentPlayer!.seek(clampSeekPosition(currentPlayer, event.position)));
|
||||
unawaited(activePlayer!.seek(clampSeekPosition(activePlayer, event.position)));
|
||||
} else if (event is NextTrackEvent) {
|
||||
appLogger.d('Media control: Next track event received');
|
||||
if (_nextEpisode != null) _playNext();
|
||||
@@ -153,34 +156,35 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
if (!mounted) return;
|
||||
|
||||
await _syncMediaControlsAvailability();
|
||||
if (!mounted || player != currentPlayer || _mediaControlsManager != mediaControlsManager) return;
|
||||
|
||||
// Listen to playing state and update media controls
|
||||
_mediaControlsPlayingSubscription = player!.streams.playing.listen((isPlaying) {
|
||||
_mediaControlsPlayingSubscription = currentPlayer.streams.playing.listen((isPlaying) {
|
||||
_updateMediaControlsPlaybackState();
|
||||
});
|
||||
|
||||
// Listen to position updates for media controls and Discord
|
||||
_mediaControlsPositionSubscription = player!.streams.position.listen((position) {
|
||||
_mediaControlsManager?.updatePlaybackState(
|
||||
isPlaying: player!.state.isActive,
|
||||
_mediaControlsPositionSubscription = currentPlayer.streams.position.listen((position) {
|
||||
mediaControlsManager.updatePlaybackState(
|
||||
isPlaying: currentPlayer.state.isActive,
|
||||
position: position,
|
||||
speed: player!.state.rate,
|
||||
speed: currentPlayer.state.rate,
|
||||
);
|
||||
DiscordRPCService.instance.updatePosition(position);
|
||||
TraktScrobbleService.instance.updatePosition(position);
|
||||
TrackerCoordinator.instance.updatePosition(position);
|
||||
// Keep Trakt's known duration current — mpv only emits on the duration
|
||||
// stream once per load, but this is cheap and avoids an extra listener.
|
||||
TraktScrobbleService.instance.updateDuration(player!.state.duration);
|
||||
TrackerCoordinator.instance.updateDuration(player!.state.duration);
|
||||
TraktScrobbleService.instance.updateDuration(currentPlayer.state.duration);
|
||||
TrackerCoordinator.instance.updateDuration(currentPlayer.state.duration);
|
||||
});
|
||||
|
||||
// Listen to playback rate changes for Discord Rich Presence
|
||||
_mediaControlsRateSubscription = player!.streams.rate.listen((rate) {
|
||||
_mediaControlsRateSubscription = currentPlayer.streams.rate.listen((rate) {
|
||||
DiscordRPCService.instance.updatePlaybackSpeed(rate);
|
||||
});
|
||||
|
||||
_mediaControlsSeekableSubscription = player!.streams.seekable.listen((_) {
|
||||
_mediaControlsSeekableSubscription = currentPlayer.streams.seekable.listen((_) {
|
||||
unawaited(_syncMediaControlsAvailability());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@ part of '../../video_player_screen.dart';
|
||||
|
||||
extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
Future<void> _startPlayback() async {
|
||||
if (!mounted) return;
|
||||
final currentPlayer = player;
|
||||
if (!mounted || currentPlayer == null) return;
|
||||
|
||||
// Live TV mode: bypass standard playback initialization
|
||||
if (widget.isLive) {
|
||||
try {
|
||||
_hasFirstFrame.value = false;
|
||||
await player!.requestAudioFocus();
|
||||
await currentPlayer.requestAudioFocus();
|
||||
await _setLiveStreamOptions();
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
|
||||
String streamUrl;
|
||||
if (_liveStreamUrl != null) {
|
||||
@@ -93,11 +95,13 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
}
|
||||
|
||||
_livePlaybackStartTime = DateTime.now();
|
||||
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
|
||||
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(() {
|
||||
@@ -107,8 +111,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
});
|
||||
_trackManager?.mediaInfo = null;
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to start live TV playback', error: e);
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to start live TV playback', error: e, stackTrace: st);
|
||||
unawaited(_sendLiveTimeline('stopped'));
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, e.toString());
|
||||
@@ -148,7 +152,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
// in parallel with MPV setup. Quality preset + server capabilities +
|
||||
// headers were resolved there too. Just await the result.
|
||||
streamHeaders = _streamHeaders;
|
||||
result = await _playbackDataFuture!;
|
||||
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;
|
||||
@@ -171,13 +180,14 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
// 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 = player is PlayerAndroid;
|
||||
final isExoPlayer = currentPlayer is PlayerAndroid;
|
||||
final isAndroidMpv = Platform.isAndroid && !isExoPlayer;
|
||||
final needsAndroidMpvFrameRateStartup = willAutoSwitch && isAndroidMpv && result.videoUrl != null;
|
||||
var didPreLoadFrameRateSwitch = false;
|
||||
@@ -191,7 +201,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
// gives us the FPS so MediaCodec starts after the display mode change.
|
||||
if (needsAndroidMpvFrameRateStartup) {
|
||||
final delaySec = settingsService.read(SettingsService.displaySwitchDelay);
|
||||
final durationMs = _currentMetadata.durationMs ?? player!.state.duration.inMilliseconds;
|
||||
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
|
||||
_suppressMediaPauseDuringFrameRateSwitch = true;
|
||||
Future.delayed(Duration(seconds: 2 + delaySec + 1), () {
|
||||
_suppressMediaPauseDuringFrameRateSwitch = false;
|
||||
@@ -201,11 +211,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
'Frame rate matching: pre-load MPV switch to ${preKnownFps}fps '
|
||||
'(duration: ${durationMs}ms, delay=${delaySec}s)',
|
||||
);
|
||||
didPreLoadFrameRateSwitch = await player!.setVideoFrameRate(
|
||||
didPreLoadFrameRateSwitch = await currentPlayer.setVideoFrameRate(
|
||||
preKnownFps,
|
||||
durationMs,
|
||||
extraDelayMs: delaySec * 1000,
|
||||
);
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
if (didPreLoadFrameRateSwitch) {
|
||||
_frameRateMatchingApplied = true;
|
||||
}
|
||||
@@ -242,8 +253,9 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
await _audioFocusFuture;
|
||||
_audioFocusFuture = null;
|
||||
} else {
|
||||
await player!.requestAudioFocus();
|
||||
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
|
||||
@@ -251,6 +263,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
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');
|
||||
@@ -265,7 +278,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
// inherits it on the ExoPlayer→MPV fallback path (see
|
||||
// _onBackendSwitched), so keep it unconditional.
|
||||
if (!_isOfflinePlayback && !widget.isLive) {
|
||||
await player!.setProperty(
|
||||
await currentPlayer.setProperty(
|
||||
'stream-lavf-o',
|
||||
'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600',
|
||||
);
|
||||
@@ -274,7 +287,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
final shouldAutoPlay = !shouldHoldPlaybackStart && (isExoPlayer || !hasExternalSubs);
|
||||
if (needsAndroidMpvStartupRefresh) {
|
||||
appLogger.d('Frame rate matching: opening Android MPV paused for startup buffer flush');
|
||||
androidMpvStartupReady = player!.streams.playbackRestart.first
|
||||
androidMpvStartupReady = currentPlayer.streams.playbackRestart.first
|
||||
.then((_) => true)
|
||||
.timeout(
|
||||
const Duration(seconds: 15),
|
||||
@@ -288,16 +301,17 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
// 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 player!.open(
|
||||
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 (player is PlayerAndroid) {
|
||||
await (player as PlayerAndroid).setSubtitleStyle(
|
||||
if (currentPlayer is PlayerAndroid) {
|
||||
await currentPlayer.setSubtitleStyle(
|
||||
fontSize: settingsService.read(SettingsService.subtitleFontSize).toDouble(),
|
||||
textColor: settingsService.read(SettingsService.subtitleTextColor),
|
||||
borderSize: settingsService.read(SettingsService.subtitleBorderSize).toDouble(),
|
||||
@@ -351,24 +365,26 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
}
|
||||
|
||||
await _initVideoFilterAndPip();
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
|
||||
if (player != null) {
|
||||
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();
|
||||
};
|
||||
if (player!.state.playing) {
|
||||
unawaited(_videoPIPManager!.updateAutoPipState(isPlaying: true));
|
||||
final pipManager = _videoPIPManager;
|
||||
if (currentPlayer.state.playing && pipManager != null) {
|
||||
unawaited(pipManager.updateAutoPipState(isPlaying: true));
|
||||
}
|
||||
}
|
||||
|
||||
// Shader Service (MPV only)
|
||||
_shaderService = ShaderService(player!);
|
||||
_shaderService = ShaderService(currentPlayer);
|
||||
if (_shaderService!.isSupported) {
|
||||
// Ambient Lighting Service
|
||||
_ambientLightingService = AmbientLightingService(player!);
|
||||
_ambientLightingService = AmbientLightingService(currentPlayer);
|
||||
_shaderService!.ambientLightingService = _ambientLightingService;
|
||||
_videoFilterManager?.ambientLightingService = _ambientLightingService;
|
||||
|
||||
@@ -376,14 +392,15 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
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: player!,
|
||||
isActive: () => mounted && player != null,
|
||||
player: currentPlayer,
|
||||
isActive: () => mounted && player == currentPlayer,
|
||||
persistTrackPreference: plexTrackClient != null ? _plexTrackPersister(() => plexTrackClient) : null,
|
||||
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
|
||||
waitForProfileSettings: _waitForProfileSettingsIfNeeded,
|
||||
@@ -401,18 +418,20 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
_trackManager!.cacheExternalSubtitles(result.externalSubtitles);
|
||||
|
||||
Future<void> resumeAfterStartupGate(String reason) async {
|
||||
if (!mounted || player == null) return;
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
final trackManager = _trackManager;
|
||||
if (trackManager == null) return;
|
||||
appLogger.d('Frame rate matching: resuming playback after $reason');
|
||||
if (player is! PlayerAndroid && hasExternalSubs) {
|
||||
await _trackManager!.resumeAfterSubtitleLoad();
|
||||
if (currentPlayer is! PlayerAndroid && hasExternalSubs) {
|
||||
await trackManager.resumeAfterSubtitleLoad();
|
||||
} else {
|
||||
await player!.play();
|
||||
await currentPlayer.play();
|
||||
}
|
||||
}
|
||||
|
||||
// MPV with external subs: add after open via sub-add,
|
||||
// opened paused to avoid race condition (issue #226)
|
||||
if (player is! PlayerAndroid && result.externalSubtitles.isNotEmpty) {
|
||||
if (currentPlayer is! PlayerAndroid && result.externalSubtitles.isNotEmpty) {
|
||||
_hasFirstFrame.value = false;
|
||||
_trackManager!.waitingForExternalSubsTrackSelection = true;
|
||||
|
||||
@@ -421,7 +440,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
} finally {
|
||||
// When a startup gate below owns the resume,
|
||||
// skip this one to avoid a double-play.
|
||||
if (!shouldHoldPlaybackStart) {
|
||||
if (!shouldHoldPlaybackStart && mounted && player == currentPlayer) {
|
||||
await _trackManager!.resumeAfterSubtitleLoad();
|
||||
}
|
||||
}
|
||||
@@ -434,17 +453,18 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
// 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 != null) {
|
||||
if (needsPostOpenFrameRateSwitch && mounted && player == currentPlayer) {
|
||||
_frameRateMatchingApplied = true;
|
||||
final delaySec = settingsService.read(SettingsService.displaySwitchDelay);
|
||||
final durationMs = _currentMetadata.durationMs ?? player!.state.duration.inMilliseconds;
|
||||
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 player!.setVideoFrameRate(preKnownFps!, durationMs, extraDelayMs: delaySec * 1000);
|
||||
didSwitch = await currentPlayer.setVideoFrameRate(preKnownFps!, durationMs, extraDelayMs: delaySec * 1000);
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
if (didSwitch) {
|
||||
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'post-open frame rate switch');
|
||||
}
|
||||
@@ -465,10 +485,10 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (needsAndroidMpvStartupRefresh && mounted && player != null) {
|
||||
} 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 != null) {
|
||||
if (mounted && player == currentPlayer) {
|
||||
if (startupReady) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'pre-load frame rate startup');
|
||||
@@ -489,12 +509,14 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
);
|
||||
}
|
||||
}
|
||||
} on PlaybackException catch (e) {
|
||||
} 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) {
|
||||
} 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()));
|
||||
|
||||
@@ -537,11 +537,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
|
||||
Future<void> _initializePlayer() async {
|
||||
var initPhase = 'starting';
|
||||
try {
|
||||
if (mounted) {
|
||||
setState(() => _playerInitializationError = null);
|
||||
}
|
||||
initPhase = 'loading settings';
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
if (!mounted) return;
|
||||
_videoPlayerNavigationEnabled = settingsService.read(SettingsService.videoPlayerNavigationEnabled);
|
||||
_autoPipEnabled = settingsService.read(SettingsService.autoPip);
|
||||
_rewindOnResume = settingsService.read(SettingsService.rewindOnResume);
|
||||
@@ -551,18 +554,22 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
final useExoPlayer = settingsService.read(SettingsService.useExoPlayer);
|
||||
|
||||
if (Platform.isWindows) {
|
||||
initPhase = 'syncing display mode';
|
||||
_displayModeService = DisplayModeService(settingsService, FullscreenStateManager());
|
||||
await _displayModeService!.syncWithNative();
|
||||
if (!mounted) return;
|
||||
FullscreenStateManager().addListener(_onFullscreenChanged);
|
||||
}
|
||||
|
||||
player = Player(useExoPlayer: useExoPlayer);
|
||||
_playerBackendLabel = player!.playerType;
|
||||
initPhase = 'creating player';
|
||||
final currentPlayer = Player(useExoPlayer: useExoPlayer);
|
||||
player = currentPlayer;
|
||||
_playerBackendLabel = currentPlayer.playerType;
|
||||
|
||||
// Kick off audio-focus negotiation in parallel with MPV config + prefetch.
|
||||
// On Android this is a round-trip to AudioManager (~90ms cold).
|
||||
if (Platform.isAndroid && !widget.isLive) {
|
||||
_audioFocusFuture = player!.requestAudioFocus();
|
||||
_audioFocusFuture = currentPlayer.requestAudioFocus();
|
||||
_audioFocusFuture!.ignore();
|
||||
}
|
||||
|
||||
@@ -613,17 +620,19 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_playbackDataFuture!.ignore();
|
||||
}
|
||||
|
||||
await player!.configureSubtitleFonts();
|
||||
await player!.setProperty('sub-ass', 'yes'); // Enable libass
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
initPhase = 'configuring player';
|
||||
await currentPlayer.configureSubtitleFonts();
|
||||
await currentPlayer.setProperty('sub-ass', 'yes'); // Enable libass
|
||||
if (Platform.isAndroid && useExoPlayer) {
|
||||
final tunneledPlayback = settingsService.read(SettingsService.tunneledPlayback);
|
||||
await player!.setProperty('tunneled-playback', tunneledPlayback ? 'yes' : 'no');
|
||||
await currentPlayer.setProperty('tunneled-playback', tunneledPlayback ? 'yes' : 'no');
|
||||
}
|
||||
if (bufferSizeMB > 0) {
|
||||
final bufferSizeBytes = bufferSizeMB * 1024 * 1024;
|
||||
await player!.setProperty('demuxer-max-bytes', bufferSizeBytes.toString());
|
||||
await currentPlayer.setProperty('demuxer-max-bytes', bufferSizeBytes.toString());
|
||||
final backBytes = bufferSizeBytes ~/ 4;
|
||||
await player!.setProperty('demuxer-max-back-bytes', backBytes.toString());
|
||||
await currentPlayer.setProperty('demuxer-max-back-bytes', backBytes.toString());
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
// Cap demuxer buffers based on device heap to prevent OOM crashes.
|
||||
@@ -631,6 +640,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// buffering, which combined with decoded frames and GPU textures
|
||||
// exhausts the process address space on memory-constrained devices.
|
||||
final heapMB = await PlayerAndroid.getHeapSize();
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
if (heapMB > 0) {
|
||||
int autoBackMB;
|
||||
if (heapMB <= 256) {
|
||||
@@ -649,75 +659,84 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
} else {
|
||||
autoForwardMB = 100;
|
||||
}
|
||||
await player!.setProperty('demuxer-max-bytes', '${autoForwardMB * 1024 * 1024}');
|
||||
await player!.setProperty('demuxer-max-back-bytes', '${autoBackMB * 1024 * 1024}');
|
||||
await currentPlayer.setProperty('demuxer-max-bytes', '${autoForwardMB * 1024 * 1024}');
|
||||
await currentPlayer.setProperty('demuxer-max-back-bytes', '${autoBackMB * 1024 * 1024}');
|
||||
} else {
|
||||
// Manual mode: cap back-buffer relative to heap if 1/4 ratio is too high
|
||||
final maxBackBytes = min(bufferSizeMB * 1024 * 1024 ~/ 4, autoBackMB * 1024 * 1024);
|
||||
await player!.setProperty('demuxer-max-back-bytes', maxBackBytes.toString());
|
||||
await currentPlayer.setProperty('demuxer-max-back-bytes', maxBackBytes.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
await player!.setProperty('msg-level', debugLoggingEnabled ? 'all=debug' : 'all=error');
|
||||
await player!.setLogLevel(debugLoggingEnabled ? 'v' : 'warn');
|
||||
await player!.setProperty('hwdec', _getHwdecValue(enableHardwareDecoding));
|
||||
await currentPlayer.setProperty('msg-level', debugLoggingEnabled ? 'all=debug' : 'all=error');
|
||||
await currentPlayer.setLogLevel(debugLoggingEnabled ? 'v' : 'warn');
|
||||
await currentPlayer.setProperty('hwdec', _getHwdecValue(enableHardwareDecoding));
|
||||
|
||||
await player!.setProperty('sub-font-size', settingsService.read(SettingsService.subtitleFontSize).toString());
|
||||
await player!.setProperty('sub-color', settingsService.read(SettingsService.subtitleTextColor));
|
||||
await player!.setProperty('sub-border-size', settingsService.read(SettingsService.subtitleBorderSize).toString());
|
||||
await player!.setProperty('sub-border-color', settingsService.read(SettingsService.subtitleBorderColor));
|
||||
await player!.setProperty('sub-bold', settingsService.read(SettingsService.subtitleBold) ? 'yes' : 'no');
|
||||
await player!.setProperty('sub-italic', settingsService.read(SettingsService.subtitleItalic) ? 'yes' : 'no');
|
||||
await currentPlayer.setProperty(
|
||||
'sub-font-size',
|
||||
settingsService.read(SettingsService.subtitleFontSize).toString(),
|
||||
);
|
||||
await currentPlayer.setProperty('sub-color', settingsService.read(SettingsService.subtitleTextColor));
|
||||
await currentPlayer.setProperty(
|
||||
'sub-border-size',
|
||||
settingsService.read(SettingsService.subtitleBorderSize).toString(),
|
||||
);
|
||||
await currentPlayer.setProperty('sub-border-color', settingsService.read(SettingsService.subtitleBorderColor));
|
||||
await currentPlayer.setProperty('sub-bold', settingsService.read(SettingsService.subtitleBold) ? 'yes' : 'no');
|
||||
await currentPlayer.setProperty(
|
||||
'sub-italic',
|
||||
settingsService.read(SettingsService.subtitleItalic) ? 'yes' : 'no',
|
||||
);
|
||||
final bgOpacity = (settingsService.read(SettingsService.subtitleBackgroundOpacity) * 255 / 100).toInt();
|
||||
final bgColor = settingsService.read(SettingsService.subtitleBackgroundColor).replaceFirst('#', '');
|
||||
await player!.setProperty(
|
||||
await currentPlayer.setProperty(
|
||||
'sub-back-color',
|
||||
'#${bgOpacity.toRadixString(16).padLeft(2, '0').toUpperCase()}$bgColor',
|
||||
);
|
||||
if (settingsService.read(SettingsService.subtitleBackgroundOpacity) > 0) {
|
||||
await player!.setProperty('sub-border-style', 'background-box');
|
||||
await currentPlayer.setProperty('sub-border-style', 'background-box');
|
||||
}
|
||||
await player!.setProperty('sub-ass-override', settingsService.read(SettingsService.subAssOverride).name);
|
||||
await player!.setProperty('sub-ass-video-aspect-override', '1');
|
||||
await player!.setProperty('sub-pos', settingsService.read(SettingsService.subtitlePosition).toString());
|
||||
await currentPlayer.setProperty('sub-ass-override', settingsService.read(SettingsService.subAssOverride).name);
|
||||
await currentPlayer.setProperty('sub-ass-video-aspect-override', '1');
|
||||
await currentPlayer.setProperty('sub-pos', settingsService.read(SettingsService.subtitlePosition).toString());
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await player!.setProperty('audio-exclusive', 'yes');
|
||||
await currentPlayer.setProperty('audio-exclusive', 'yes');
|
||||
}
|
||||
|
||||
// Audio passthrough (desktop only - sends bitstream to receiver)
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
if (settingsService.read(SettingsService.audioPassthrough)) {
|
||||
await player!.setAudioPassthrough(true);
|
||||
await currentPlayer.setAudioPassthrough(true);
|
||||
}
|
||||
}
|
||||
|
||||
// HDR is controlled via custom hdr-enabled property on iOS/macOS/Windows
|
||||
if (Platform.isIOS || Platform.isMacOS || Platform.isWindows) {
|
||||
final enableHDR = settingsService.read(SettingsService.enableHDR);
|
||||
await player!.setProperty('hdr-enabled', enableHDR ? 'yes' : 'no');
|
||||
await currentPlayer.setProperty('hdr-enabled', enableHDR ? 'yes' : 'no');
|
||||
}
|
||||
|
||||
final audioSyncOffset = settingsService.read(SettingsService.audioSyncOffset);
|
||||
if (audioSyncOffset != 0) {
|
||||
final offsetSeconds = audioSyncOffset / 1000.0;
|
||||
await player!.setProperty('audio-delay', offsetSeconds.toString());
|
||||
await currentPlayer.setProperty('audio-delay', offsetSeconds.toString());
|
||||
}
|
||||
|
||||
final subtitleSyncOffset = settingsService.read(SettingsService.subtitleSyncOffset);
|
||||
if (subtitleSyncOffset != 0) {
|
||||
final offsetSeconds = subtitleSyncOffset / 1000.0;
|
||||
await player!.setProperty('sub-delay', offsetSeconds.toString());
|
||||
await currentPlayer.setProperty('sub-delay', offsetSeconds.toString());
|
||||
}
|
||||
|
||||
if (settingsService.read(SettingsService.audioNormalization)) {
|
||||
await player!.setProperty('af', 'loudnorm=I=-14:TP=-3:LRA=4');
|
||||
await currentPlayer.setProperty('af', 'loudnorm=I=-14:TP=-3:LRA=4');
|
||||
}
|
||||
|
||||
final customMpvConfig = SettingsService.parseMpvConfigText(settingsService.read(SettingsService.mpvConfigText));
|
||||
for (final entry in customMpvConfig.entries) {
|
||||
try {
|
||||
await player!.setProperty(entry.key, entry.value);
|
||||
await currentPlayer.setProperty(entry.key, entry.value);
|
||||
appLogger.d('Applied custom MPV property: ${entry.key}=${entry.value}');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to set MPV property ${entry.key}', error: e);
|
||||
@@ -725,10 +744,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
|
||||
final maxVolume = settingsService.read(SettingsService.maxVolume);
|
||||
await player!.setProperty('volume-max', maxVolume.toString());
|
||||
await currentPlayer.setProperty('volume-max', maxVolume.toString());
|
||||
|
||||
final savedVolume = settingsService.read(SettingsService.volume).clamp(0.0, maxVolume.toDouble());
|
||||
await player!.setVolume(savedVolume);
|
||||
await currentPlayer.setVolume(savedVolume);
|
||||
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -736,19 +757,19 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
});
|
||||
|
||||
// Restart sleep timer if we're starting a new playback session
|
||||
final p = player;
|
||||
if (p != null) {
|
||||
SleepTimerService().restartIfNeeded(() => p.pause());
|
||||
}
|
||||
SleepTimerService().restartIfNeeded(() => currentPlayer.pause());
|
||||
|
||||
// Enable wakelock to prevent screen from turning off during playback
|
||||
unawaited(_setWakelock(true));
|
||||
appLogger.d('Wakelock enabled for video playback');
|
||||
}
|
||||
|
||||
initPhase = 'starting playback';
|
||||
await _startPlayback();
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
|
||||
// Set fullscreen mode and orientation based on rotation lock setting
|
||||
initPhase = 'applying orientation';
|
||||
if (mounted) {
|
||||
try {
|
||||
// Check rotation lock setting before applying orientation
|
||||
@@ -768,6 +789,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
initPhase = 'wiring player streams';
|
||||
await Future.wait<void>([
|
||||
if (_playingSubscription != null) _playingSubscription!.cancel(),
|
||||
if (_completedSubscription != null) _completedSubscription!.cancel(),
|
||||
@@ -779,33 +802,34 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (_playbackRestartSubscription != null) _playbackRestartSubscription!.cancel(),
|
||||
if (_positionSubscription != null) _positionSubscription!.cancel(),
|
||||
]);
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
|
||||
_playingSubscription = player!.streams.playing.listen(_onPlayingStateChanged);
|
||||
_playingSubscription = currentPlayer.streams.playing.listen(_onPlayingStateChanged);
|
||||
|
||||
// Listen to completion. When mpv emits completed=false (file-loaded after a
|
||||
// reconnect-seek or fresh open), clear a stale _completionTriggered so the
|
||||
// real end-of-file can still show Play Next. Guarded against clobbering an
|
||||
// active dialog or running auto-play countdown.
|
||||
_completedSubscription = player!.streams.completed.listen((done) {
|
||||
_completedSubscription = currentPlayer.streams.completed.listen((done) {
|
||||
if (!done && _completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) {
|
||||
_completionTriggered = false;
|
||||
}
|
||||
_onVideoCompleted(done);
|
||||
});
|
||||
|
||||
_errorSubscription = player!.streams.error.listen(_onPlayerError);
|
||||
_errorSubscription = currentPlayer.streams.error.listen(_onPlayerError);
|
||||
|
||||
// warn is included so we can catch ffmpeg's "HTTP error 500" line in
|
||||
// _onPlayerLog — the error-level log that follows omits the status code.
|
||||
_logSubscription = player!.streams.log
|
||||
_logSubscription = currentPlayer.streams.log
|
||||
.where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level))
|
||||
.listen(_onPlayerLog);
|
||||
|
||||
if (Platform.isAndroid && useExoPlayer) {
|
||||
_backendSwitchedSubscription = player!.streams.backendSwitched.listen((_) => _onBackendSwitched());
|
||||
_backendSwitchedSubscription = currentPlayer.streams.backendSwitched.listen((_) => _onBackendSwitched());
|
||||
}
|
||||
|
||||
_bufferingSubscription = player!.streams.buffering.listen((isBuffering) {
|
||||
_bufferingSubscription = currentPlayer.streams.buffering.listen((isBuffering) {
|
||||
_isBuffering.value = isBuffering;
|
||||
});
|
||||
|
||||
@@ -829,7 +853,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
}
|
||||
|
||||
_playbackRestartSubscription = player!.streams.playbackRestart.listen((_) async {
|
||||
_playbackRestartSubscription = currentPlayer.streams.playbackRestart.listen((_) async {
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
_lastLogError = null;
|
||||
_sawServer500 = false;
|
||||
_liveStreamFallbackLevel = 0;
|
||||
@@ -849,9 +874,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
});
|
||||
|
||||
int? lastObservedPositionMs;
|
||||
_positionSubscription = player!.streams.position.listen((position) {
|
||||
final currentPlayer = player;
|
||||
if (currentPlayer == null) return;
|
||||
_positionSubscription = currentPlayer.streams.position.listen((position) {
|
||||
final activePlayer = player;
|
||||
if (activePlayer == null || activePlayer != currentPlayer) return;
|
||||
|
||||
// Fallback for cases where playbackRestart doesn't fire (observed on
|
||||
// some offline Android playback flows). Prevents a permanent loading
|
||||
@@ -872,7 +897,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
lastObservedPositionMs = position.inMilliseconds;
|
||||
}
|
||||
|
||||
final duration = currentPlayer.state.duration;
|
||||
final duration = activePlayer.state.duration;
|
||||
if (duration.inMilliseconds > 0 &&
|
||||
position.inMilliseconds >= duration.inMilliseconds - 1000 &&
|
||||
!_showPlayNextDialog &&
|
||||
@@ -893,9 +918,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (mounted) _loadAdjacentEpisodes();
|
||||
}),
|
||||
);
|
||||
initPhase = 'initializing playback services';
|
||||
await _initializeServices();
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to initialize player', error: e);
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to initialize player during $initPhase', error: e, stackTrace: st);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPlayerInitialized = false;
|
||||
|
||||
Reference in New Issue
Block a user