fix(player): harden initialization lifecycle

This commit is contained in:
edde746
2026-05-13 06:28:09 +02:00
parent b0f1a5d4f1
commit 9af6f32157
4 changed files with 186 additions and 125 deletions
+22 -13
View File
@@ -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()));
+77 -51
View File
@@ -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;