fix(ui): block forward seeks via Plyr seek listener, not after the fact

Clamping currentTime in the seeking/seeked handlers is ignored on progressive
MP4 (the forward seek takes effect immediately), so the must-watch lock leaked
even though the snackbar showed.

Block at the source: a Plyr `seek` listener returns false for targets past the
watched point, which skips Plyr's default seek so the media never moves.
Rewinding and re-seeking up to the watched point stay allowed; the snackbar
fires when a forward skip is blocked. furthestSeconds is now shared between the
listener and the playback tracker. The seeking/seeked clamp is kept as a
backstop for keyboard/programmatic seeks that bypass the progress bar.
This commit is contained in:
Rotimi Best
2026-06-23 10:12:32 +01:00
parent bbdb2e9419
commit dff0476ac2
@@ -47,6 +47,38 @@
let isMounted = false; let isMounted = false;
let youtubeInitGeneration = 0; let youtubeInitGeneration = 0;
const SEEK_TOLERANCE_SECONDS = 0.5;
/**
* Furthest watched position for the must-watch lock. Shared between the
* progress-bar `seek` listener (set at Plyr construction) and the playback
* tracker in `attachSeekEnforcement`, which advances it as the user watches.
*/
let seekLockFurthestSeconds = options.seekPolicy?.initialFurthestSeconds ?? 0;
/**
* Plyr progress-bar seek handler. Returning `false` skips Plyr's default
* seek, so the media never moves — the reliable way to block forward
* skips (clamping `currentTime` after the fact is ignored on progressive
* MP4). Rewinding and re-seeking up to the watched point stay allowed.
*/
function handleSeekAttempt(event: Event): boolean | void {
const policy = options.seekPolicy;
if (policy?.mode !== 'locked_until_complete') return;
const input = event.currentTarget as HTMLInputElement | null;
const duration = playerInstance?.duration;
if (!input || !duration || !Number.isFinite(duration)) return;
const max = Number(input.max) || 100;
const raw = input.getAttribute('seek-value') ?? input.value;
const targetSeconds = (Number(raw) / max) * duration;
if (targetSeconds > seekLockFurthestSeconds + SEEK_TOLERANCE_SECONDS) {
policy.onSeekBlocked?.();
return false;
}
}
interface HlsQualityConfig { interface HlsQualityConfig {
default: number; default: number;
options: number[]; options: number[];
@@ -80,14 +112,13 @@
element: HTMLVideoElement, element: HTMLVideoElement,
policy: NonNullable<MediaPlayerOptions['seekPolicy']> policy: NonNullable<MediaPlayerOptions['seekPolicy']>
): () => void { ): () => void {
let furthestSeconds = policy.initialFurthestSeconds ?? 0; seekLockFurthestSeconds = policy.initialFurthestSeconds ?? 0;
let playedSeconds = 0; let playedSeconds = 0;
let lastValidTime = element.currentTime; let lastValidTime = element.currentTime;
let isSeeking = false; let isSeeking = false;
let isReclamping = false; let isReclamping = false;
let seekBlockNotified = false; let seekBlockNotified = false;
let lastHeartbeatAt = 0; let lastHeartbeatAt = 0;
const seekTolerance = 0.5;
const heartbeatIntervalMs = 15_000; const heartbeatIntervalMs = 15_000;
const flushProgress = (force = false) => { const flushProgress = (force = false) => {
@@ -106,7 +137,7 @@
playedSeconds = 0; playedSeconds = 0;
}; };
const isAheadOfLimit = () => player.currentTime > furthestSeconds + seekTolerance; const isAheadOfLimit = () => player.currentTime > seekLockFurthestSeconds + SEEK_TOLERANCE_SECONDS;
const notifySeekBlocked = () => { const notifySeekBlocked = () => {
if (seekBlockNotified) return; if (seekBlockNotified) return;
@@ -115,21 +146,20 @@
policy.onSeekBlocked?.(); policy.onSeekBlocked?.();
}; };
// Immediate clamp. For HLS this short-circuits the forward fragment fetch // Backstop for seeks that bypass the progress-bar listener (keyboard
// so the player never buffers ahead. For progressive MP4 the write is // shortcuts, programmatic). The progress bar itself is blocked up-front
// often ignored mid-seek, so `onSeeked` re-asserts it once settled. // by the `seek` listener wired at construction.
const onSeeking = () => { const onSeeking = () => {
isSeeking = true; isSeeking = true;
if (isReclamping) return; if (isReclamping) return;
if (isAheadOfLimit()) { if (isAheadOfLimit()) {
notifySeekBlocked(); notifySeekBlocked();
player.currentTime = furthestSeconds; player.currentTime = seekLockFurthestSeconds;
} }
}; };
const onSeeked = () => { const onSeeked = () => {
// Consume the `seeked` produced by our own clamp write, then settle.
if (isReclamping) { if (isReclamping) {
isReclamping = false; isReclamping = false;
isSeeking = false; isSeeking = false;
@@ -138,13 +168,10 @@
return; return;
} }
// Authoritative enforcement: if the seek landed past the limit (the
// mid-seek clamp didn't hold, e.g. progressive MP4), snap back now —
// a backward seek into buffered content, which holds reliably.
if (isAheadOfLimit()) { if (isAheadOfLimit()) {
isReclamping = true; isReclamping = true;
notifySeekBlocked(); notifySeekBlocked();
player.currentTime = furthestSeconds; player.currentTime = seekLockFurthestSeconds;
return; return;
} }
@@ -161,7 +188,7 @@
if (delta > 0 && delta < 2) { if (delta > 0 && delta < 2) {
playedSeconds += delta; playedSeconds += delta;
furthestSeconds = Math.max(furthestSeconds, current); seekLockFurthestSeconds = Math.max(seekLockFurthestSeconds, current);
} }
lastValidTime = current; lastValidTime = current;
@@ -184,7 +211,7 @@
if (tailSeconds > 0) { if (tailSeconds > 0) {
playedSeconds += tailSeconds; playedSeconds += tailSeconds;
} }
furthestSeconds = Math.max(furthestSeconds, durationSeconds); seekLockFurthestSeconds = Math.max(seekLockFurthestSeconds, durationSeconds);
lastValidTime = durationSeconds; lastValidTime = durationSeconds;
} }
@@ -377,6 +404,7 @@
ratio: '16:9', ratio: '16:9',
iconUrl: '/plyr.svg', iconUrl: '/plyr.svg',
iconPrefix: 'plyr', iconPrefix: 'plyr',
...(options.seekPolicy?.mode === 'locked_until_complete' ? { listeners: { seek: handleSeekAttempt } } : {}),
...(qualityConfig ...(qualityConfig
? { ? {
quality: qualityConfig, quality: qualityConfig,