From 08491511e9b2f35b7b30df5746def849067ab7db Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:52:14 +0200 Subject: [PATCH] fix(video): prevent play next EOF loop close #1228 --- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 3 ++ .../video_player/parts/playback_prompts.dart | 15 ++++++- .../video_player/parts/playback_services.dart | 4 +- lib/screens/video_player_screen.dart | 39 +++++++++++++------ .../apple/MpvPlayer/MpvPlayerCoreBase.swift | 3 ++ 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index 1850ec04..94ef22fa 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -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) } diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index 0a561639..b9858b00 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -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; diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 17bbabee..461e7655 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -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)); } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6c60f87e..06b36180 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -352,6 +352,13 @@ class VideoPlayerScreenState extends State 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 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 with WidgetsBindin } final duration = activePlayer.state.duration; - if (duration.inMilliseconds > 0 && - position.inMilliseconds >= duration.inMilliseconds - 1000 && - !_showPlayNextDialog && - !_completionTriggered) { - _onVideoCompleted(true); + 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(); + } } }); diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index 250e9f97..36bcdbfd 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -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)