fix: avoid early playback completion

This commit is contained in:
edde746
2026-06-27 06:46:32 +02:00
parent 120749e442
commit 3b611a1e18
5 changed files with 27 additions and 47 deletions
+9 -23
View File
@@ -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);
@@ -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;
});
@@ -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.
});
+3 -4
View File
@@ -337,10 +337,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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;
@@ -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', () {