diff --git a/lib/screens/video_player/parts/pip.dart b/lib/screens/video_player/parts/pip.dart index 6aa8a64f..7e121367 100644 --- a/lib/screens/video_player/parts/pip.dart +++ b/lib/screens/video_player/parts/pip.dart @@ -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 _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 _togglePIPMode() async { diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index ff67e341..20f38179 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -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 _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()); }); } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 802117a9..04e16910 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -2,14 +2,16 @@ part of '../../video_player_screen.dart'; extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { Future _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().profileSettings, waitForProfileSettings: _waitForProfileSettingsIfNeeded, @@ -401,18 +418,20 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _trackManager!.cacheExternalSubtitles(result.externalSubtitles); Future 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.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())); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index f99d3a18..7fa1e5ca 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -537,11 +537,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin } Future _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 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 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 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 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 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 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 with WidgetsBindin } } + if (!mounted || player != currentPlayer) return; + initPhase = 'wiring player streams'; await Future.wait([ if (_playingSubscription != null) _playingSubscription!.cancel(), if (_completedSubscription != null) _completedSubscription!.cancel(), @@ -779,33 +802,34 @@ class VideoPlayerScreenState extends State 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 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 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 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 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;