From 3b611a1e18881a8d990c76b3c26b4ae65b59a38e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 27 Jun 2026 06:46:32 +0200 Subject: [PATCH] fix: avoid early playback completion --- .../video_player/completion_latch.dart | 32 ++++++------------- .../video_player/parts/playback_prompts.dart | 7 ++-- .../video_player/parts/playback_services.dart | 5 +-- lib/screens/video_player_screen.dart | 7 ++-- .../video_player/completion_latch_test.dart | 23 +++++++------ 5 files changed, 27 insertions(+), 47 deletions(-) diff --git a/lib/screens/video_player/completion_latch.dart b/lib/screens/video_player/completion_latch.dart index 056f9404..55ae6b22 100644 --- a/lib/screens/video_player/completion_latch.dart +++ b/lib/screens/video_player/completion_latch.dart @@ -3,11 +3,6 @@ enum CompletionLatchSignal { /// Nothing to do. none, - /// Playback just entered the end-of-video window and the latch is clear — - /// the caller should run its completion handling (which latches on - /// success via [CompletionLatch.latch]). - completed, - /// Playback moved back out of the end region and the latch re-armed. rearmed, } @@ -15,22 +10,17 @@ enum CompletionLatchSignal { /// End-of-video latch with rearm hysteresis for the Play Next / completion /// prompts. /// -/// The prompt fires when playback enters the last [triggerWindowMs] of the -/// item and must not re-fire on every subsequent position tick — the latch -/// stays set while playback is parked inside the end region. It re-arms only -/// once playback moves back out past [rearmWindowMs] (a larger window, so a -/// position oscillating at the boundary can't flap), and never while a -/// prompt is visible or an auto-play countdown owns the screen. +/// Completion itself comes from the player's EOF signal. The latch prevents +/// that handling from re-running while playback is parked at EOF, and re-arms +/// only once playback moves back out past [rearmWindowMs] from the end. It +/// never re-arms while a prompt is visible or an auto-play countdown owns the +/// screen. /// -/// Latching is the *caller's* move ([latch]), not [classifyPosition]'s: the -/// completion handler has its own bail-outs (live TV, in-flight media swap) -/// and a tick that bails must stay un-latched so the next tick retries. +/// Latching is the *caller's* move ([latch]), not [classifyPosition]'s: the EOF +/// handler has its own bail-outs (live TV, in-flight media swap) and a signal +/// that bails must stay un-latched so the next EOF signal retries. class CompletionLatch { - CompletionLatch({required this.triggerWindowMs, required this.rearmWindowMs}) - : assert(rearmWindowMs > triggerWindowMs, 'rearm window must exceed trigger window for hysteresis'); - - /// Fire when within this many ms of the end. - final int triggerWindowMs; + CompletionLatch({required this.rearmWindowMs}); /// Re-arm only after moving back out past this many ms from the end. final int rearmWindowMs; @@ -63,10 +53,6 @@ class CompletionLatch { required bool countdownActive, }) { if (durationMs <= 0) return CompletionLatchSignal.none; - if (positionMs >= durationMs - triggerWindowMs) { - if (!promptVisible && !_triggered) return CompletionLatchSignal.completed; - return CompletionLatchSignal.none; - } if (positionMs < durationMs - rearmWindowMs) { final wasLatched = _triggered; rearmIfClear(promptVisible: promptVisible, countdownActive: countdownActive); diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index 753390f2..e252d23d 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -100,10 +100,9 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { _autoPlayTimer?.cancel(); _unfocusPlayNextPrompt(); _progressTracker?.resumeAfterStoppedReport(); - // 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. + // Keep the latch set while playback is still parked at EOF, so duplicate + // completed signals cannot re-open this prompt. It is re-armed once playback + // seeks back clear of the end region (see the position listener) or new media loads. _setPlayerState(() { _showPlayNextDialog = false; }); diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 36ad369b..c85cead2 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -147,15 +147,12 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { } final duration = activePlayer.state.duration; - final signal = _completionLatch.classifyPosition( + _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. }); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index a597b74a..6ccd0c01 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -337,10 +337,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin Timer? _autoPlayTimer; int _autoPlayCountdown = 5; - // End-of-video Play Next latch. Fires within 1s of the end; re-arms only - // once playback is more than 2s from the end — the gap is hysteresis so a - // position parked at the boundary can't oscillate (see CompletionLatch). - final CompletionLatch _completionLatch = CompletionLatch(triggerWindowMs: 1000, rearmWindowMs: 2000); + // End-of-video Play Next latch. Completion comes from the player EOF signal; + // position ticks only re-arm once playback is more than 2s from the end. + final CompletionLatch _completionLatch = CompletionLatch(rearmWindowMs: 2000); late final FocusNode _playNextCancelFocusNode; late final FocusNode _playNextConfirmFocusNode; diff --git a/test/screens/video_player/completion_latch_test.dart b/test/screens/video_player/completion_latch_test.dart index 37ceea9a..bcf9d8df 100644 --- a/test/screens/video_player/completion_latch_test.dart +++ b/test/screens/video_player/completion_latch_test.dart @@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/screens/video_player/completion_latch.dart'; void main() { - CompletionLatch latch() => CompletionLatch(triggerWindowMs: 1000, rearmWindowMs: 2000); + CompletionLatch latch() => CompletionLatch(rearmWindowMs: 2000); CompletionLatchSignal tick( CompletionLatch l, @@ -19,19 +19,18 @@ void main() { ); } - test('signals completed once inside the trigger window, then stays quiet while latched', () { + test('position ticks near the end do not signal completion', () { final l = latch(); expect(tick(l, 58000), CompletionLatchSignal.none); - expect(tick(l, 59200), CompletionLatchSignal.completed); - // The handler latches on success; until then ticks keep retrying. - expect(tick(l, 59300), CompletionLatchSignal.completed); - l.latch(); - expect(tick(l, 59400), CompletionLatchSignal.none); + expect(tick(l, 59200), CompletionLatchSignal.none); + expect(tick(l, 60000), CompletionLatchSignal.none); }); - test('does not fire while a prompt is visible', () { + test('stays quiet while latched at EOF', () { final l = latch(); - expect(tick(l, 59500, promptVisible: true), CompletionLatchSignal.none); + l.latch(); + expect(tick(l, 59400), CompletionLatchSignal.none); + expect(l.triggered, isTrue); }); test('ignores ticks with no known duration', () { @@ -42,14 +41,14 @@ void main() { test('re-arms only after moving back past the rearm window', () { final l = latch(); l.latch(); - // Inside the hysteresis gap (between trigger and rearm windows): no flap. + // Inside the rearm window: no flap. expect(tick(l, 58500), CompletionLatchSignal.none); expect(l.triggered, isTrue); // Clearly out of the end region: re-armed. expect(tick(l, 50000), CompletionLatchSignal.rearmed); expect(l.triggered, isFalse); - // Returning to the end can fire again. - expect(tick(l, 59500), CompletionLatchSignal.completed); + // Returning to the end stays quiet until the player emits EOF. + expect(tick(l, 59500), CompletionLatchSignal.none); }); test('refuses to re-arm while a prompt or countdown is active', () {