fix: avoid early playback completion
This commit is contained in:
@@ -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.
|
||||
});
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user