feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user