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:
@@ -33,10 +33,20 @@
|
||||
const MAX_SCALE = 1.5;
|
||||
|
||||
let stageElement = $state<HTMLDivElement | null>(null);
|
||||
let iframeElement = $state<HTMLIFrameElement | null>(null);
|
||||
let manualScale = $state<number | null>(typeof zoom === 'number' ? clamp(zoom) : null);
|
||||
let fitScale = $state(0.4);
|
||||
|
||||
const srcdoc = $derived(renderCertificateDocument(design, data));
|
||||
const renderedHtml = $derived(renderCertificateDocument(design, data));
|
||||
|
||||
$effect(() => {
|
||||
const html = renderedHtml;
|
||||
const doc = iframeElement?.contentDocument;
|
||||
if (!doc) return;
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
});
|
||||
|
||||
const isFit = $derived(zoom === 'fit' && manualScale == null);
|
||||
const scale = $derived(isFit ? fitScale : (manualScale ?? clamp(typeof zoom === 'number' ? zoom : 0.4)));
|
||||
@@ -84,12 +94,12 @@
|
||||
>
|
||||
<div class="ui:origin-center" style:width="{width}px" style:height="{height}px" style:transform="scale({scale})">
|
||||
<iframe
|
||||
bind:this={iframeElement}
|
||||
title="Certificate preview"
|
||||
sandbox="allow-same-origin"
|
||||
class="ui:h-full ui:w-full ui:rounded-sm ui:border-0 ui:shadow-[0_18px_40px_rgba(0,0,0,0.18),0_6px_12px_rgba(0,0,0,0.12)]"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
{srcdoc}
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/** Marker teachers use in the template for each blank (three underscores). */
|
||||
export const WORD_BANK_BLANK_MARKER = '___';
|
||||
|
||||
/** Any run of 3+ underscores counts as a single blank, so `___` and `______` behave the same. */
|
||||
export const WORD_BANK_BLANK_PATTERN = /_{3,}/g;
|
||||
|
||||
export type WordBankSegment = { type: 'text'; value: string } | { type: 'blank'; index: number };
|
||||
|
||||
export function countWordBankBlanks(template: string): number {
|
||||
if (!template) return 0;
|
||||
const parts = template.split(WORD_BANK_BLANK_MARKER);
|
||||
return Math.max(0, parts.length - 1);
|
||||
const matches = template.match(WORD_BANK_BLANK_PATTERN);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
/** Splits template into alternating text runs and blank slots (indices0..n-1). */
|
||||
@@ -14,7 +17,7 @@ export function parseWordBankTemplate(template: string): WordBankSegment[] {
|
||||
const segments: WordBankSegment[] = [];
|
||||
if (!template) return segments;
|
||||
|
||||
const parts = template.split(WORD_BANK_BLANK_MARKER);
|
||||
const parts = template.split(WORD_BANK_BLANK_PATTERN);
|
||||
let blankIndex = 0;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
|
||||
@@ -75,6 +75,16 @@ class ImageCropperRootState {
|
||||
this.pixelCrop = undefined;
|
||||
}
|
||||
|
||||
onUseOriginal() {
|
||||
if (!this.tempUrl) return;
|
||||
|
||||
this.opts.src.current = this.tempUrl;
|
||||
this.open = false;
|
||||
this.opts.onCropped.current(this.tempUrl);
|
||||
this.tempUrl = undefined;
|
||||
this.pixelCrop = undefined;
|
||||
}
|
||||
|
||||
async onCrop() {
|
||||
if (!this.pixelCrop || !this.tempUrl) return;
|
||||
|
||||
@@ -146,6 +156,16 @@ class ImageCropperCancelState {
|
||||
}
|
||||
}
|
||||
|
||||
class ImageCropperUseOriginalState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onclick = this.onclick.bind(this);
|
||||
}
|
||||
|
||||
onclick() {
|
||||
this.rootState.onUseOriginal();
|
||||
}
|
||||
}
|
||||
|
||||
const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root');
|
||||
|
||||
export const useImageCropperRoot = (props: ImageCropperRootProps) => {
|
||||
@@ -187,3 +207,9 @@ export const useImageCropperCancel = () => {
|
||||
|
||||
return new ImageCropperCancelState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperUseOriginal = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperUseOriginalState(rootState);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '../../base/dialog';
|
||||
import Button from '../../base/button/button.svelte';
|
||||
import { cn } from '../../tools';
|
||||
import { useImageCropperDialog } from './image-cropper-context.svelte';
|
||||
import type { ImageCropperDialogProps } from './types';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
|
||||
let { children, class: className, ...rest }: ImageCropperDialogProps = $props();
|
||||
|
||||
@@ -12,12 +14,22 @@
|
||||
<Dialog.Root bind:open={dialogState.rootState.open}>
|
||||
<Dialog.Content
|
||||
{...rest}
|
||||
showCloseButton
|
||||
showCloseButton={false}
|
||||
class={cn(
|
||||
'ui:min-h-96 ui:max-w-[60%] ui:rounded-none ui:border-x-0 ui:sm:ui:max-w-lg ui:sm:ui:rounded-lg ui:sm:ui:border-x',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="ui:absolute ui:end-4 ui:top-4"
|
||||
onclick={() => dialogState.rootState.onCancel()}
|
||||
>
|
||||
<XIcon />
|
||||
<span class="ui:sr-only">Close</span>
|
||||
</Button>
|
||||
<div class="ui:flex ui:flex-col ui:gap-4">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import Button, { type ButtonElementProps } from '../../base/button/button.svelte';
|
||||
import { useImageCropperUseOriginal } from './image-cropper-context.svelte';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
onclick,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonElementProps = $props();
|
||||
|
||||
const useOriginalState = useImageCropperUseOriginal();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
{...rest}
|
||||
bind:ref
|
||||
{size}
|
||||
{variant}
|
||||
onclick={(
|
||||
e: MouseEvent & {
|
||||
currentTarget: EventTarget & HTMLButtonElement;
|
||||
}
|
||||
) => {
|
||||
onclick?.(e);
|
||||
|
||||
useOriginalState.onclick();
|
||||
}}
|
||||
>
|
||||
<ImageIcon />
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<span>Don't Crop</span>
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -6,8 +6,9 @@ import Cropper from './image-cropper-cropper.svelte';
|
||||
import Controls from './image-cropper-controls.svelte';
|
||||
import Crop from './image-cropper-crop.svelte';
|
||||
import Cancel from './image-cropper-cancel.svelte';
|
||||
import UseOriginal from './image-cropper-use-original.svelte';
|
||||
import { getFileFromUrl } from './utils';
|
||||
|
||||
export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, getFileFromUrl };
|
||||
export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, UseOriginal, getFileFromUrl };
|
||||
|
||||
export type * from './types';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import PercentRingProgress from './percent-ring-progress.svelte';
|
||||
|
||||
export { PercentRingProgress };
|
||||
export { PercentRingProgress as default };
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
const RING_RADIUS = 45;
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
|
||||
|
||||
interface Props {
|
||||
/** Progress value from 0 to 100 */
|
||||
value: number;
|
||||
size?: 'small' | 'default';
|
||||
}
|
||||
|
||||
let { value, size = 'small' }: Props = $props();
|
||||
|
||||
const clampedValue = $derived(Math.max(0, Math.min(100, value)));
|
||||
const labelPercentage = $derived(Math.round(clampedValue));
|
||||
const strokeDashoffset = $derived(RING_CIRCUMFERENCE * (1 - clampedValue / 100));
|
||||
const sizeClass = $derived(size === 'small' ? 'size-10' : 'size-14');
|
||||
const labelClass = $derived(size === 'small' ? 'text-[10px]' : 'text-sm');
|
||||
</script>
|
||||
|
||||
<div class="relative shrink-0">
|
||||
<svg
|
||||
class="{sizeClass} -rotate-90"
|
||||
style="transform-box: fill-box; transform-origin: center;"
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
class="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
class="text-green-600 transition-[stroke-dashoffset] duration-700 ease-out"
|
||||
stroke-dasharray={RING_CIRCUMFERENCE}
|
||||
stroke-dashoffset={strokeDashoffset}
|
||||
/>
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p class={labelClass}>{labelPercentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user