fix(player): wire playback streams before open

This commit is contained in:
edde746
2026-06-15 12:05:48 +02:00
parent 27723e6f29
commit 5fc599439e
2 changed files with 139 additions and 128 deletions
@@ -32,6 +32,135 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
);
}
Future<void> _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<void> _wirePlayerStreams({
required Player currentPlayer,
required SettingsService settingsService,
required bool useExoPlayer,
}) async {
await Future.wait<void>([
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<MultiServerProvider>().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
+10 -128
View File
@@ -877,6 +877,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<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(),
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<MultiServerProvider>().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