feat: add sequential progression and video watch lesson completion

Gate student content by completion order, track per-video watch progress
with seek enforcement, and auto-complete lessons at the configured
threshold. Show ring progress in the lesson header, keep watch progress
on rewatch, and sync progression locks client-side so the next item
unlocks after completion.
This commit is contained in:
Rotimi Best
2026-06-11 14:48:34 +01:00
parent c8154409f5
commit 52d9bf18c3
94 changed files with 3499 additions and 564 deletions
@@ -2,6 +2,7 @@
import { onDestroy, onMount } from 'svelte';
import 'plyr/dist/plyr.css';
import CaptionsIcon from '@lucide/svelte/icons/captions';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
import type { MediaPlayerOptions, VideoTextTrack } from '../types';
import { getYoutubeVideoId, isYoutubeUrl } from '../utils';
import { PLYR_DEFAULT_CONTROLS } from './constants';
@@ -31,6 +32,7 @@
let playerInstance = $state<Plyr | null>(null);
let transcriptButtonElement = $state<HTMLButtonElement | null>(null);
let playbackFailed = $state(false);
let isPlayerReady = $state(false);
let reloadInFlight = $state(false);
let timeupdateCleanup: (() => void) | undefined;
let firstPlayCleanup: (() => void) | undefined;
@@ -60,7 +62,12 @@
const isYouTube = $derived(isYoutubeUrl(src));
const youtubeVideoId = $derived(isYouTube ? getYoutubeVideoId(src) : null);
function attachTimeUpdates(player: Plyr): () => void {
function attachTimeUpdates(player: Plyr, element: HTMLVideoElement): () => void {
const policy = options.seekPolicy;
if (policy?.mode === 'locked_until_complete') {
return attachSeekEnforcement(player, element, policy);
}
const handler = () => options.onTimeUpdate?.(player.currentTime);
player.on('timeupdate', handler);
return () => {
@@ -68,6 +75,134 @@
};
}
function attachSeekEnforcement(
player: Plyr,
element: HTMLVideoElement,
policy: NonNullable<MediaPlayerOptions['seekPolicy']>
): () => void {
let furthestSeconds = policy.initialFurthestSeconds ?? 0;
let playedSeconds = 0;
let lastValidTime = element.currentTime;
let isSeeking = false;
let hasBlockedThisSeek = false;
let lastHeartbeatAt = 0;
const seekTolerance = 0.5;
const heartbeatIntervalMs = 15_000;
const flushProgress = (force = false) => {
const durationSeconds = element.duration;
if (!durationSeconds || !Number.isFinite(durationSeconds)) return;
const now = Date.now();
if (!force && now - lastHeartbeatAt < heartbeatIntervalMs) return;
lastHeartbeatAt = now;
policy.onProgress?.({
positionSeconds: Math.floor(player.currentTime),
playedDeltaSeconds: Math.round(playedSeconds),
durationSeconds: Math.round(durationSeconds)
});
playedSeconds = 0;
};
const onSeeking = () => {
isSeeking = true;
if (!hasBlockedThisSeek && player.currentTime > furthestSeconds + seekTolerance) {
hasBlockedThisSeek = true;
player.currentTime = furthestSeconds;
policy.onSeekBlocked?.();
}
};
const onSeeked = () => {
isSeeking = false;
hasBlockedThisSeek = false;
lastValidTime = player.currentTime;
};
const onTimeUpdate = () => {
if (isSeeking) return;
const current = player.currentTime;
const delta = current - lastValidTime;
if (delta > 0 && delta < 2) {
playedSeconds += delta;
furthestSeconds = Math.max(furthestSeconds, current);
}
lastValidTime = current;
options.onTimeUpdate?.(current);
flushProgress(false);
};
const onRateChange = () => {
if (player.speed !== 1) {
player.speed = 1;
}
};
const onPause = () => flushProgress(true);
const onEnded = () => {
const durationSeconds = element.duration;
if (durationSeconds && Number.isFinite(durationSeconds)) {
const tailSeconds = durationSeconds - lastValidTime;
if (tailSeconds > 0) {
playedSeconds += tailSeconds;
}
furthestSeconds = Math.max(furthestSeconds, durationSeconds);
lastValidTime = durationSeconds;
}
flushProgress(true);
};
player.on('seeking', onSeeking);
player.on('seeked', onSeeked);
player.on('timeupdate', onTimeUpdate);
player.on('ratechange', onRateChange);
player.on('pause', onPause);
player.on('ended', onEnded);
return () => {
flushProgress(true);
player.off('seeking', onSeeking);
player.off('seeked', onSeeked);
player.off('timeupdate', onTimeUpdate);
player.off('ratechange', onRateChange);
player.off('pause', onPause);
player.off('ended', onEnded);
};
}
function getPlyrControls(): string[] {
if (!showControls) return [];
return PLYR_DEFAULT_CONTROLS;
}
function handleVisibilityChange(): void {
if (!options.seekPolicy?.pauseOnHidden) return;
if (!document.hidden) return;
try {
playerInstance?.pause();
} catch {
// Player may be mid-teardown.
}
}
function handleWindowBlur(): void {
if (!options.seekPolicy?.pauseOnHidden) return;
try {
playerInstance?.pause();
} catch {
// Player may be mid-teardown.
}
}
function attachFirstPlay(player: Plyr): () => void {
const handler = () => {
if (hasFiredFirstPlay) return;
@@ -172,6 +307,7 @@
transcriptPanelCleanup = undefined;
plyrErrorCleanup = undefined;
mediaErrorCleanup = undefined;
isPlayerReady = false;
if (!playerInstance) return;
try {
@@ -190,7 +326,7 @@
const PlyrModule = await import('plyr');
const PlyrConstructor = PlyrModule.default;
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
const controls = getPlyrControls();
try {
// Recheck after the dynamic import resolves — the component may have
@@ -215,9 +351,12 @@
}
: {})
});
timeupdateCleanup = attachTimeUpdates(playerInstance);
timeupdateCleanup = attachTimeUpdates(playerInstance, videoElement);
firstPlayCleanup = attachFirstPlay(playerInstance);
plyrErrorCleanup = attachPlyrErrorHandler(playerInstance);
playerInstance.on('ready', () => {
isPlayerReady = true;
});
options.onPlayerReady?.(playerInstance);
} finally {
plyrConstructInFlight = false;
@@ -267,7 +406,7 @@
const PlyrModule = await import('plyr');
const PlyrConstructor = PlyrModule.default;
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
const controls = getPlyrControls();
try {
if (!isMounted || initGeneration !== youtubeInitGeneration || !containerElement || playerInstance) {
@@ -284,6 +423,9 @@
timeupdateCleanup = attachTimeUpdates(playerInstance);
firstPlayCleanup = attachFirstPlay(playerInstance);
plyrErrorCleanup = attachPlyrErrorHandler(playerInstance);
playerInstance.on('ready', () => {
isPlayerReady = true;
});
options.onPlayerReady?.(playerInstance);
} finally {
plyrConstructInFlight = false;
@@ -494,6 +636,9 @@
});
</script>
<svelte:document onvisibilitychange={handleVisibilityChange} />
<svelte:window onblur={handleWindowBlur} />
<div
class="ui:relative ui:aspect-video ui:w-full ui:overflow-hidden ui:rounded-md"
style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};"
@@ -530,6 +675,20 @@
</video>
{/if}
{#if !isPlayerReady && !playbackFailed}
<div
class="ui:absolute ui:inset-0 ui:z-10 ui:flex ui:items-center ui:justify-center ui:bg-black/40"
role="status"
aria-live="polite"
aria-label={options.loadingLabel}
>
<LoaderCircleIcon class="ui:size-10 ui:animate-spin ui:text-white" aria-hidden="true" />
{#if options.loadingLabel}
<span class="ui:sr-only">{options.loadingLabel}</span>
{/if}
</div>
{/if}
{#if playbackFailed && options.playbackErrorLabel}
<div
class="ui:absolute ui:inset-0 ui:z-20 ui:flex ui:flex-col ui:items-center ui:justify-center ui:gap-4 ui:bg-muted/95 ui:px-6 ui:text-center"
@@ -44,6 +44,8 @@ export interface MediaPlayerOptions {
onPlayerReady?: (player: Plyr) => void;
/** Shown when the media element fails to load or decode. */
playbackErrorLabel?: string;
/** Accessible label for the loading/buffering spinner overlay. */
loadingLabel?: string;
/** Label for the manual reload control shown after a playback failure. */
playbackReloadLabel?: string;
/** Fetch a fresh playback URL (or otherwise prepare a retry). Return true to retry loading. */
@@ -66,4 +68,12 @@ export interface MediaPlayerOptions {
* best-effort call that fails benignly.
*/
onBeforeHlsLoad?: () => Promise<void>;
seekPolicy?: {
mode: 'locked_until_complete';
watchThresholdPercent: number;
initialFurthestSeconds?: number;
pauseOnHidden?: boolean;
onProgress?: (payload: { positionSeconds: number; playedDeltaSeconds: number; durationSeconds: number }) => void;
onSeekBlocked?: () => void;
};
}