feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -11,6 +11,7 @@
let { source, options = {}, class: className = '' }: Props = $props();
const tracks = $derived(source.tracks ?? []);
const isMuse = $derived.by(() => source.type === 'muse' && source.metadata?.svid);
const isGoogleDrive = $derived(source.type === 'google_drive');
const poster = $derived(source.type === 'upload' ? source.metadata?.thumbnailUrl : undefined);
@@ -39,6 +40,6 @@
></iframe>
</div>
{:else}
<PlyrPlayer src={source.url} {poster} {options} />
<PlyrPlayer src={source.url} {poster} {options} {tracks} />
{/if}
</div>
@@ -1,7 +1,8 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import 'plyr/dist/plyr.css';
import type { MediaPlayerOptions } from '../types';
import CaptionsIcon from '@lucide/svelte/icons/captions';
import type { MediaPlayerOptions, VideoTextTrack } from '../types';
import { getYoutubeVideoId, isYoutubeUrl } from '../utils';
import { PLYR_DEFAULT_CONTROLS } from './constants';
import type Plyr from 'plyr';
@@ -10,13 +11,19 @@
src: string;
poster?: string;
options?: MediaPlayerOptions;
tracks?: VideoTextTrack[];
}
let { src, poster, options = {} }: Props = $props();
let { src, poster, options = {}, tracks = [] }: Props = $props();
let videoElement = $state<HTMLVideoElement | null>(null);
let containerElement = $state<HTMLDivElement | null>(null);
let playerInstance = $state<Plyr | null>(null);
let transcriptButtonElement = $state<HTMLButtonElement | null>(null);
let timeupdateCleanup: (() => void) | undefined;
let firstPlayCleanup: (() => void) | undefined;
let transcriptPanelCleanup: (() => void) | undefined;
let hasFiredFirstPlay = false;
const playsinline = $derived(options.playsinline !== false);
const maxHeight = $derived(options.maxHeight || '400px');
@@ -26,34 +33,108 @@
const isYouTube = $derived(isYoutubeUrl(src));
const youtubeVideoId = $derived(isYouTube ? getYoutubeVideoId(src) : null);
onMount(async () => {
const PlyrModule = await import('plyr');
const PlyrConstructor = PlyrModule.default;
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
function attachTimeUpdates(player: Plyr): () => void {
const handler = () => options.onTimeUpdate?.(player.currentTime);
player.on('timeupdate', handler);
return () => {
player.off('timeupdate', handler);
};
}
if (isYouTube && youtubeVideoId && containerElement) {
playerInstance = new PlyrConstructor(containerElement, {
controls,
autoplay,
ratio: '16:9',
iconUrl: '/plyr.svg',
iconPrefix: 'plyr'
});
return;
function attachFirstPlay(player: Plyr): () => void {
const handler = () => {
if (hasFiredFirstPlay) return;
hasFiredFirstPlay = true;
options.onFirstPlay?.();
};
player.on('play', handler);
return () => {
player.off('play', handler);
};
}
function attachTranscriptPanelControl(
player: Plyr,
template: HTMLButtonElement,
control: NonNullable<MediaPlayerOptions['transcriptPanelControl']>
): () => void {
type PlyrElements = { controls?: HTMLElement | null };
const controls = (player as unknown as { elements: PlyrElements }).elements?.controls;
if (!controls) return () => {};
controls.querySelector('[data-cio-transcript-panel]')?.remove();
const button = template.cloneNode(true) as HTMLButtonElement;
button.removeAttribute('hidden');
const onClick = (event: MouseEvent) => {
event.stopPropagation();
control.onClick();
};
button.addEventListener('click', onClick);
const fullscreen = controls.querySelector('[data-plyr="fullscreen"]');
if (fullscreen) {
fullscreen.insertAdjacentElement('beforebegin', button);
} else {
controls.appendChild(button);
}
if (videoElement) {
playerInstance = new PlyrConstructor(videoElement, {
controls,
autoplay,
ratio: '16:9',
iconUrl: '/plyr.svg',
iconPrefix: 'plyr'
});
}
return () => {
button.removeEventListener('click', onClick);
button.remove();
};
}
onMount(() => {
void (async () => {
const PlyrModule = await import('plyr');
const PlyrConstructor = PlyrModule.default;
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
if (isYouTube && youtubeVideoId && containerElement) {
playerInstance = new PlyrConstructor(containerElement, {
controls,
autoplay,
ratio: '16:9',
iconUrl: '/plyr.svg',
iconPrefix: 'plyr'
});
timeupdateCleanup = attachTimeUpdates(playerInstance);
firstPlayCleanup = attachFirstPlay(playerInstance);
options.onPlayerReady?.(playerInstance);
return;
}
if (videoElement) {
playerInstance = new PlyrConstructor(videoElement, {
controls,
autoplay,
// Native files expose their own width/height — let Plyr fit to them instead of
// forcing 16:9 and showing black letterbox bars around smaller/portrait videos.
iconUrl: '/plyr.svg',
iconPrefix: 'plyr'
});
timeupdateCleanup = attachTimeUpdates(playerInstance);
firstPlayCleanup = attachFirstPlay(playerInstance);
if (options.transcriptPanelControl && transcriptButtonElement) {
transcriptPanelCleanup = attachTranscriptPanelControl(
playerInstance,
transcriptButtonElement,
options.transcriptPanelControl
);
}
options.onPlayerReady?.(playerInstance);
}
})();
});
onDestroy(() => {
timeupdateCleanup?.();
firstPlayCleanup?.();
transcriptPanelCleanup?.();
if (!playerInstance) return;
try {
@@ -72,10 +153,7 @@
});
</script>
<div
class="ui:relative ui:rounded-md"
style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};"
>
<div class="ui:relative" style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};">
{#if isYouTube && youtubeVideoId}
<div
bind:this={containerElement}
@@ -85,6 +163,47 @@
></div>
{:else}
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player"></video>
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player">
{#each tracks as track (track.src + track.srclang)}
<track
kind={track.kind ?? 'captions'}
src={track.src}
srclang={track.srclang}
label={track.label}
default={track.default ?? false}
/>
{/each}
</video>
{/if}
{#if options.transcriptPanelControl}
<button
bind:this={transcriptButtonElement}
type="button"
class="plyr__control"
data-cio-transcript-panel
aria-label={options.transcriptPanelControl.label}
hidden
>
<CaptionsIcon class="custom" aria-hidden="true" focusable="false" size={18} />
<span class="plyr__tooltip" role="tooltip">{options.transcriptPanelControl.label}</span>
</button>
{/if}
</div>
<style>
/* Replace Plyr's default black letterbox background. Theme `muted` blends with the
surrounding lesson area instead of looking like a TV bezel around smaller videos. */
:global(.plyr) {
--plyr-video-background: var(--muted);
}
:global(.plyr__control[data-cio-transcript-panel][hidden]) {
display: none !important;
}
:global(.plyr__control[data-cio-transcript-panel] svg.custom) {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
}
</style>
@@ -1,5 +1,15 @@
import type Plyr from 'plyr';
export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse' | 'google_drive';
export interface VideoTextTrack {
src: string;
srclang: string;
label: string;
default?: boolean;
kind?: 'captions' | 'subtitles';
}
export interface VideoSource {
type: VideoType;
url: string;
@@ -8,6 +18,7 @@ export interface VideoSource {
thumbnailUrl?: string;
title?: string;
};
tracks?: VideoTextTrack[];
}
export interface MediaPlayerOptions {
@@ -19,4 +30,18 @@ export interface MediaPlayerOptions {
height?: number | string;
class?: string;
playsinline?: boolean;
/** Fires on HTML5 timeupdate (seconds). */
onTimeUpdate?: (seconds: number) => void;
/** Fires the first time playback transitions to playing in this player session. */
onFirstPlay?: () => void;
/** After Plyr is constructed (seek via `player.currentTime = n`). */
onPlayerReady?: (player: Plyr) => void;
/**
* HTML5 playback only (not YouTube/embed): adds a control-bar button after Plyr inits.
* Use for actions like reopening a transcript side panel without toggling on-video captions.
*/
transcriptPanelControl?: {
label: string;
onClick: () => void;
};
}