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("vd-lavc-film-grain", "cpu")
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) {
setOption("display-fps-override", displayFpsOverride)
}
@@ -99,12 +99,25 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
_autoPlayTimer?.cancel();
_unfocusPlayNextPrompt();
_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(() {
_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() {
// Don't show if auto-play dialog is already visible
if (_showPlayNextDialog) return;
@@ -323,9 +323,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
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,
// so the real end-of-file can trigger Play Next after we recover.
if (_completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) {
_completionTriggered = false;
}
_rearmCompletionLatch();
unawaited(_seekPlayback(pos));
}
}
+25 -8
View File
@@ -352,6 +352,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
int _autoPlayCountdown = 5;
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 _playNextConfirmFocusNode;
@@ -881,13 +888,18 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_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) {
if (!done && _completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) {
_completionTriggered = false;
// 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 - _kPlayNextRearmMs) {
_rearmCompletionLatch();
}
}
_onVideoCompleted(done);
});
@@ -973,11 +985,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
final duration = activePlayer.state.duration;
if (duration.inMilliseconds > 0 &&
position.inMilliseconds >= duration.inMilliseconds - 1000 &&
if (duration.inMilliseconds > 0) {
if (position.inMilliseconds >= duration.inMilliseconds - _kPlayNextTriggerMs &&
!_showPlayNextDialog &&
!_completionTriggered) {
_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-software-fallback", "yes"))
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)