From 5fc599439e9b40d8b4099d7fabe930747f460fea Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:05:48 +0200 Subject: [PATCH] fix(player): wire playback streams before open --- .../video_player/parts/playback_services.dart | 129 ++++++++++++++++ lib/screens/video_player_screen.dart | 138 ++---------------- 2 files changed, 139 insertions(+), 128 deletions(-) diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 54b74502..36ad369b 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -32,6 +32,135 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { ); } + Future _markFirstFrameReady(Player currentPlayer, SettingsService settingsService) async { + if (!mounted || player != currentPlayer || _hasFirstFrame.value) return; + + _hasFirstFrame.value = true; + unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player'))); + + if (Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate)) { + await _applyFrameRateMatching(); + } + + if (Platform.isWindows && _displayModeService != null) { + await _applyWindowsDisplayMatching(); + } + } + + Future _wirePlayerStreams({ + required Player currentPlayer, + required SettingsService settingsService, + required bool useExoPlayer, + }) async { + await Future.wait([ + if (_playingSubscription != null) _playingSubscription!.cancel(), + if (_completedSubscription != null) _completedSubscription!.cancel(), + if (_errorSubscription != null) _errorSubscription!.cancel(), + if (_logSubscription != null) _logSubscription!.cancel(), + if (_backendSwitchedSubscription != null) _backendSwitchedSubscription!.cancel(), + if (_bufferingSubscription != null) _bufferingSubscription!.cancel(), + if (_serverStatusSubscription != null) _serverStatusSubscription!.cancel(), + if (_playbackRestartSubscription != null) _playbackRestartSubscription!.cancel(), + if (_positionSubscription != null) _positionSubscription!.cancel(), + ]); + if (!mounted || player != currentPlayer) return; + + _playingSubscription = currentPlayer.streams.playing.listen(_onPlayingStateChanged); + + _completedSubscription = currentPlayer.streams.completed.listen((done) { + // completed=false means a file (re)loaded after a reconnect-seek or fresh + // open — re-arm the end-of-video latch so the real EOF can still show Play + // Next. But only when playback is clear of the end region: a stray + // completed=false while parked at EOF must NOT re-arm, or the position + // listener would immediately re-fire the Play Next prompt. + if (!done) { + final durMs = currentPlayer.state.duration.inMilliseconds; + final posMs = currentPlayer.state.position.inMilliseconds; + if (durMs <= 0 || posMs < durMs - _completionLatch.rearmWindowMs) { + _rearmCompletionLatch(); + } + } + _onVideoCompleted(done); + }); + + _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 = currentPlayer.streams.log + .where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level)) + .listen(_onPlayerLog); + + if (Platform.isAndroid && useExoPlayer) { + _backendSwitchedSubscription = currentPlayer.streams.backendSwitched.listen((_) => _onBackendSwitched()); + } + + _bufferingSubscription = currentPlayer.streams.buffering.listen((isBuffering) { + _isBuffering.value = isBuffering; + }); + + // When server comes back online while buffering, force mpv to reconnect + // immediately instead of waiting for ffmpeg's exponential backoff. + if (!_isOfflinePlayback && !widget.isLive) { + final serverId = _currentMetadata.serverId; + if (serverId != null) { + final serverManager = context.read().serverManager; + bool wasOffline = false; + _serverStatusSubscription = serverManager.statusStream.listen((statusMap) { + final isOnline = statusMap[serverId] == true; + if (!isOnline) { + wasOffline = true; + } else if (wasOffline && _isBuffering.value) { + wasOffline = false; + _forceStreamReconnect(); + } + }); + } + } + + _playbackRestartSubscription = currentPlayer.streams.playbackRestart.listen((_) async { + if (!mounted || player != currentPlayer) return; + _lastLogError = null; + _sawServer500 = false; + _live.fallbackLevel = 0; + final markFirstFrameReady = _markFirstFrameReady(currentPlayer, settingsService); + _trackManager?.onPlaybackRestart(); + await markFirstFrameReady; + }); + + int? lastObservedPositionMs; + _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 + // spinner. Checking `position > 0` was broken for resume playback — + // the native layer sets position to the resume offset before the first + // frame renders, so the fallback tripped immediately. Requiring a + // position *change* ensures we only fire when playback is advancing. + if (!_hasFirstFrame.value) { + if (lastObservedPositionMs != null && position.inMilliseconds != lastObservedPositionMs) { + unawaited(_markFirstFrameReady(currentPlayer, settingsService)); + } + lastObservedPositionMs = position.inMilliseconds; + } + + final duration = activePlayer.state.duration; + final signal = _completionLatch.classifyPosition( + positionMs: position.inMilliseconds, + durationMs: duration.inMilliseconds, + promptVisible: _showPlayNextDialog, + countdownActive: _autoPlayTimer?.isActive == true, + ); + if (signal == CompletionLatchSignal.completed) { + _onVideoCompleted(true); + } + // CompletionLatchSignal.rearmed needs no action here: the latch + // re-armed itself once playback seeked back out of the end region. + }); + } + /// Wire the per-item playback services that need to (re)bind whenever /// the active media item changes: [PlaybackProgressTracker], /// [MediaControlsManager.updateMetadata], and the diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index e824f3b6..07b1f69c 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -877,6 +877,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (!mounted || player != currentPlayer) return; + initPhase = 'wiring player streams'; + await _wirePlayerStreams( + currentPlayer: currentPlayer, + settingsService: settingsService, + useExoPlayer: useExoPlayer, + ); + if (!mounted || player != currentPlayer) return; + if (mounted) { setState(() { _isPlayerInitialized = true; @@ -916,134 +924,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(), - if (_errorSubscription != null) _errorSubscription!.cancel(), - if (_logSubscription != null) _logSubscription!.cancel(), - if (_backendSwitchedSubscription != null) _backendSwitchedSubscription!.cancel(), - if (_bufferingSubscription != null) _bufferingSubscription!.cancel(), - if (_serverStatusSubscription != null) _serverStatusSubscription!.cancel(), - if (_playbackRestartSubscription != null) _playbackRestartSubscription!.cancel(), - if (_positionSubscription != null) _positionSubscription!.cancel(), - ]); - if (!mounted || player != currentPlayer) return; - - _playingSubscription = currentPlayer.streams.playing.listen(_onPlayingStateChanged); - - _completedSubscription = currentPlayer.streams.completed.listen((done) { - // completed=false means a file (re)loaded after a reconnect-seek or fresh - // open — re-arm the end-of-video latch so the real EOF can still show Play - // Next. But only when playback is clear of the end region: a stray - // completed=false while parked at EOF must NOT re-arm, or the position - // listener would immediately re-fire the Play Next prompt. - if (!done) { - final durMs = currentPlayer.state.duration.inMilliseconds; - final posMs = currentPlayer.state.position.inMilliseconds; - if (durMs <= 0 || posMs < durMs - _completionLatch.rearmWindowMs) { - _rearmCompletionLatch(); - } - } - _onVideoCompleted(done); - }); - - _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 = currentPlayer.streams.log - .where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level)) - .listen(_onPlayerLog); - - if (Platform.isAndroid && useExoPlayer) { - _backendSwitchedSubscription = currentPlayer.streams.backendSwitched.listen((_) => _onBackendSwitched()); - } - - _bufferingSubscription = currentPlayer.streams.buffering.listen((isBuffering) { - _isBuffering.value = isBuffering; - }); - - // When server comes back online while buffering, force mpv to reconnect - // immediately instead of waiting for ffmpeg's exponential backoff - if (!_isOfflinePlayback && !widget.isLive) { - final serverId = _currentMetadata.serverId; - if (serverId != null) { - if (!mounted) return; - final serverManager = context.read().serverManager; - bool wasOffline = false; - _serverStatusSubscription = serverManager.statusStream.listen((statusMap) { - final isOnline = statusMap[serverId] == true; - if (!isOnline) { - wasOffline = true; - } else if (wasOffline && _isBuffering.value) { - wasOffline = false; - _forceStreamReconnect(); - } - }); - } - } - - _playbackRestartSubscription = currentPlayer.streams.playbackRestart.listen((_) async { - if (!mounted || player != currentPlayer) return; - _lastLogError = null; - _sawServer500 = false; - _live.fallbackLevel = 0; - if (!_hasFirstFrame.value) { - _hasFirstFrame.value = true; - unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player'))); - - if (Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate)) { - await _applyFrameRateMatching(); - } - - if (Platform.isWindows && _displayModeService != null) { - await _applyWindowsDisplayMatching(); - } - } - _trackManager?.onPlaybackRestart(); - }); - - int? lastObservedPositionMs; - _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 - // spinner. Checking `position > 0` was broken for resume playback — - // the native layer sets position to the resume offset before the first - // frame renders, so the fallback tripped immediately. Requiring a - // position *change* ensures we only fire when playback is advancing. - if (!_hasFirstFrame.value) { - if (lastObservedPositionMs != null && position.inMilliseconds != lastObservedPositionMs) { - _hasFirstFrame.value = true; - - // Apply frame rate matching here too, since this fallback may fire - // before playbackRestart (race condition with resume positions > 0) - if (Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate)) { - _applyFrameRateMatching(); - } - } - lastObservedPositionMs = position.inMilliseconds; - } - - final duration = activePlayer.state.duration; - final signal = _completionLatch.classifyPosition( - positionMs: position.inMilliseconds, - durationMs: duration.inMilliseconds, - promptVisible: _showPlayNextDialog, - countdownActive: _autoPlayTimer?.isActive == true, - ); - if (signal == CompletionLatchSignal.completed) { - _onVideoCompleted(true); - } - // CompletionLatchSignal.rearmed needs no action here: the latch - // re-armed itself once playback seeked back out of the end region. - }); - - // Services init must finish before first frame so Discord / Trakt / - // Tracker start-playback calls are dispatched pre-first-frame. + // Player streams are wired before open so broadcast first-frame events + // cannot be dropped. Service init follows immediately after open. // `_loadAdjacentEpisodes` depends on the play queue being in state // (EpisodeNavigationService bails when !isQueueActive), so chain it // after `_ensurePlayQueue`. Both stay fire-and-forget so HTTP latency