fix(video): prevent play next EOF loop

close #1228
This commit is contained in:
edde746
2026-06-02 15:52:14 +02:00
parent 24b5080c38
commit 08491511e9
5 changed files with 49 additions and 15 deletions
@@ -238,6 +238,9 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
setOption("opengl-es", "yes") setOption("opengl-es", "yes")
setOption("vd-lavc-film-grain", "cpu") setOption("vd-lavc-film-grain", "cpu")
setOption("ao", "audiotrack,opensles") setOption("ao", "audiotrack,opensles")
// Pause on the last frame at EOF instead of unloading the file, so a
// seek after the video ends still works (matches Linux/Windows).
setOption("keep-open", "yes")
if (displayFpsOverride != null) { if (displayFpsOverride != null) {
setOption("display-fps-override", displayFpsOverride) setOption("display-fps-override", displayFpsOverride)
} }
@@ -99,12 +99,25 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
_autoPlayTimer?.cancel(); _autoPlayTimer?.cancel();
_unfocusPlayNextPrompt(); _unfocusPlayNextPrompt();
_progressTracker?.resumeAfterStoppedReport(); _progressTracker?.resumeAfterStoppedReport();
_completionTriggered = false; // Reset so it can trigger again if user seeks near end // Keep _completionTriggered set: playback is still parked inside the
// end-of-video window, so clearing it here would let the position listener
// re-fire this prompt on the next tick. It is re-armed once playback seeks
// back clear of the end region (see the position listener) or new media loads.
_setPlayerState(() { _setPlayerState(() {
_showPlayNextDialog = false; _showPlayNextDialog = false;
}); });
} }
/// Re-arm the end-of-video latch so Play Next can fire again — but only when no
/// prompt is visible and no auto-play countdown is running, so we never clobber
/// an active dialog. Callers decide *when* it is safe to re-arm (media reloaded,
/// or playback moved back out of the end region).
void _rearmCompletionLatch() {
if (_completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) {
_completionTriggered = false;
}
}
void _showStillWatchingDialog() { void _showStillWatchingDialog() {
// Don't show if auto-play dialog is already visible // Don't show if auto-play dialog is already visible
if (_showPlayNextDialog) return; if (_showPlayNextDialog) return;
@@ -323,9 +323,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
appLogger.i('Network restored while buffering, forcing stream reconnect at ${pos.inSeconds}s'); appLogger.i('Network restored while buffering, forcing stream reconnect at ${pos.inSeconds}s');
// Clear any stale completion latch caused by a spurious EOF during the drop, // Clear any stale completion latch caused by a spurious EOF during the drop,
// so the real end-of-file can trigger Play Next after we recover. // so the real end-of-file can trigger Play Next after we recover.
if (_completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) { _rearmCompletionLatch();
_completionTriggered = false;
}
unawaited(_seekPlayback(pos)); unawaited(_seekPlayback(pos));
} }
} }
+28 -11
View File
@@ -352,6 +352,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
int _autoPlayCountdown = 5; int _autoPlayCountdown = 5;
bool _completionTriggered = false; bool _completionTriggered = false;
// End-of-video Play Next thresholds. Fire the prompt within _kPlayNextTriggerMs
// of the end; re-arm (allow it to fire again) only once playback is more than
// _kPlayNextRearmMs from the end. The gap is hysteresis so a position parked at
// the boundary can't oscillate between firing and re-arming.
static const int _kPlayNextTriggerMs = 1000;
static const int _kPlayNextRearmMs = 2000;
late final FocusNode _playNextCancelFocusNode; late final FocusNode _playNextCancelFocusNode;
late final FocusNode _playNextConfirmFocusNode; late final FocusNode _playNextConfirmFocusNode;
@@ -881,13 +888,18 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_playingSubscription = currentPlayer.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 = currentPlayer.streams.completed.listen((done) { _completedSubscription = currentPlayer.streams.completed.listen((done) {
if (!done && _completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) { // completed=false means a file (re)loaded after a reconnect-seek or fresh
_completionTriggered = false; // 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 - _kPlayNextRearmMs) {
_rearmCompletionLatch();
}
} }
_onVideoCompleted(done); _onVideoCompleted(done);
}); });
@@ -973,11 +985,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
} }
final duration = activePlayer.state.duration; final duration = activePlayer.state.duration;
if (duration.inMilliseconds > 0 && if (duration.inMilliseconds > 0) {
position.inMilliseconds >= duration.inMilliseconds - 1000 && if (position.inMilliseconds >= duration.inMilliseconds - _kPlayNextTriggerMs &&
!_showPlayNextDialog && !_showPlayNextDialog &&
!_completionTriggered) { !_completionTriggered) {
_onVideoCompleted(true); _onVideoCompleted(true);
} else if (position.inMilliseconds < duration.inMilliseconds - _kPlayNextRearmMs) {
// Seeked back out of the end region after dismissing Play Next — re-arm
// so the prompt can fire again if the user returns to the end.
_rearmCompletionLatch();
}
} }
}); });
@@ -708,6 +708,9 @@ class MpvPlayerCoreBase: NSObject {
checkError(mpv_set_option_string(mpv, "hwdec-codecs", "all")) checkError(mpv_set_option_string(mpv, "hwdec-codecs", "all"))
checkError(mpv_set_option_string(mpv, "hwdec-software-fallback", "yes")) checkError(mpv_set_option_string(mpv, "hwdec-software-fallback", "yes"))
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "auto")) checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "auto"))
// Pause on the last frame at EOF instead of unloading the file, so seeking
// back after the video ends still works (matches Linux/Windows).
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
} }
#if os(macOS) #if os(macOS)