feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it
This commit is contained in:
@@ -5,26 +5,51 @@
|
||||
import type { MediaPlayerOptions, VideoTextTrack } from '../types';
|
||||
import { getYoutubeVideoId, isYoutubeUrl } from '../utils';
|
||||
import { PLYR_DEFAULT_CONTROLS } from './constants';
|
||||
import { Button } from '../../../base/button';
|
||||
import type Plyr from 'plyr';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
poster?: string;
|
||||
/**
|
||||
* When true, `src` is an HLS master playlist (.m3u8). The player
|
||||
* attaches hls.js (or uses native HLS on Safari) instead of setting
|
||||
* the URL on the `<video>` element directly. Cookie minting (if any)
|
||||
* happens via `options.onBeforeHlsLoad`.
|
||||
*/
|
||||
hls?: boolean;
|
||||
options?: MediaPlayerOptions;
|
||||
tracks?: VideoTextTrack[];
|
||||
}
|
||||
|
||||
let { src, poster, options = {}, tracks = [] }: Props = $props();
|
||||
let { src, poster, hls = false, options = {}, tracks = [] }: Props = $props();
|
||||
|
||||
let hlsInstance = $state<import('hls.js').default | null>(null);
|
||||
|
||||
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 playbackFailed = $state(false);
|
||||
let reloadInFlight = $state(false);
|
||||
let timeupdateCleanup: (() => void) | undefined;
|
||||
let firstPlayCleanup: (() => void) | undefined;
|
||||
let transcriptPanelCleanup: (() => void) | undefined;
|
||||
let mediaErrorCleanup: (() => void) | undefined;
|
||||
let plyrErrorCleanup: (() => void) | undefined;
|
||||
let loadedSrc = $state<string | null>(null);
|
||||
let hlsLoadGeneration = 0;
|
||||
let hlsPlyrGeneration = 0;
|
||||
let plyrConstructInFlight = false;
|
||||
let hasFiredFirstPlay = false;
|
||||
|
||||
interface HlsQualityConfig {
|
||||
default: number;
|
||||
options: number[];
|
||||
forced: boolean;
|
||||
onChange: (height: number) => void;
|
||||
}
|
||||
|
||||
const playsinline = $derived(options.playsinline !== false);
|
||||
const maxHeight = $derived(options.maxHeight || '400px');
|
||||
const showControls = $derived(options.controls !== false);
|
||||
@@ -54,6 +79,52 @@
|
||||
};
|
||||
}
|
||||
|
||||
function handlePlaybackFailure(): void {
|
||||
if (playbackFailed) return;
|
||||
|
||||
playbackFailed = true;
|
||||
}
|
||||
|
||||
async function handlePlaybackReloadClick(): Promise<void> {
|
||||
if (reloadInFlight || !options.onPlaybackReload) return;
|
||||
|
||||
reloadInFlight = true;
|
||||
|
||||
try {
|
||||
const reloaded = await options.onPlaybackReload();
|
||||
if (!reloaded) return;
|
||||
|
||||
playbackFailed = false;
|
||||
|
||||
if (videoElement && !isYouTube) {
|
||||
loadedSrc = null;
|
||||
videoElement.load();
|
||||
}
|
||||
} finally {
|
||||
reloadInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function attachMediaErrorHandler(element: HTMLVideoElement): () => void {
|
||||
const handler = () => {
|
||||
handlePlaybackFailure();
|
||||
};
|
||||
element.addEventListener('error', handler);
|
||||
return () => {
|
||||
element.removeEventListener('error', handler);
|
||||
};
|
||||
}
|
||||
|
||||
function attachPlyrErrorHandler(player: Plyr): () => void {
|
||||
const handler = () => {
|
||||
handlePlaybackFailure();
|
||||
};
|
||||
player.on('error', handler);
|
||||
return () => {
|
||||
player.off('error', handler);
|
||||
};
|
||||
}
|
||||
|
||||
function attachTranscriptPanelControl(
|
||||
player: Plyr,
|
||||
template: HTMLButtonElement,
|
||||
@@ -88,13 +159,110 @@
|
||||
};
|
||||
}
|
||||
|
||||
function teardownPlyrInstance(): void {
|
||||
timeupdateCleanup?.();
|
||||
firstPlayCleanup?.();
|
||||
transcriptPanelCleanup?.();
|
||||
plyrErrorCleanup?.();
|
||||
mediaErrorCleanup?.();
|
||||
timeupdateCleanup = undefined;
|
||||
firstPlayCleanup = undefined;
|
||||
transcriptPanelCleanup = undefined;
|
||||
plyrErrorCleanup = undefined;
|
||||
mediaErrorCleanup = undefined;
|
||||
if (!playerInstance) return;
|
||||
|
||||
try {
|
||||
playerInstance.destroy();
|
||||
} catch {
|
||||
// Plyr may be mid-teardown if the user closed the dialog.
|
||||
} finally {
|
||||
playerInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function constructPlyrForVideoElement(qualityConfig?: HlsQualityConfig): Promise<void> {
|
||||
if (!videoElement || playerInstance || plyrConstructInFlight) return;
|
||||
|
||||
plyrConstructInFlight = true;
|
||||
|
||||
const PlyrModule = await import('plyr');
|
||||
const PlyrConstructor = PlyrModule.default;
|
||||
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
|
||||
|
||||
try {
|
||||
// Recheck after the dynamic import resolves — the component may have
|
||||
// unmounted (or the videoElement bind nulled out) while we awaited.
|
||||
if (!videoElement || playerInstance) return;
|
||||
|
||||
mediaErrorCleanup = attachMediaErrorHandler(videoElement);
|
||||
|
||||
playerInstance = new PlyrConstructor(videoElement, {
|
||||
controls,
|
||||
autoplay,
|
||||
// Match the YouTube branch: force 16:9 so non-16:9 sources (e.g.
|
||||
// screen recordings at 4:3 or 5:4) get letterboxed inside the player
|
||||
// container rather than overflowing it and clipping the controls.
|
||||
ratio: '16:9',
|
||||
iconUrl: '/plyr.svg',
|
||||
iconPrefix: 'plyr',
|
||||
...(qualityConfig
|
||||
? {
|
||||
quality: qualityConfig,
|
||||
i18n: { qualityLabel: { 0: 'Auto' } }
|
||||
}
|
||||
: {})
|
||||
});
|
||||
timeupdateCleanup = attachTimeUpdates(playerInstance);
|
||||
firstPlayCleanup = attachFirstPlay(playerInstance);
|
||||
plyrErrorCleanup = attachPlyrErrorHandler(playerInstance);
|
||||
options.onPlayerReady?.(playerInstance);
|
||||
} finally {
|
||||
plyrConstructInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function initHlsPlyr(hls: import('hls.js').default, loadGeneration: number): Promise<void> {
|
||||
if (loadGeneration !== hlsLoadGeneration || hlsInstance !== hls) return;
|
||||
if (hlsPlyrGeneration === loadGeneration) return;
|
||||
|
||||
hlsPlyrGeneration = loadGeneration;
|
||||
|
||||
const heights = Array.from(new Set(hls.levels.map((level) => level.height).filter((height) => height > 0))).sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
|
||||
if (loadGeneration !== hlsLoadGeneration || hlsInstance !== hls || !videoElement) return;
|
||||
|
||||
if (!heights.length) {
|
||||
await constructPlyrForVideoElement();
|
||||
return;
|
||||
}
|
||||
|
||||
await constructPlyrForVideoElement({
|
||||
default: 0,
|
||||
options: [0, ...heights],
|
||||
forced: true,
|
||||
onChange: (height: number) => {
|
||||
if (hlsInstance !== hls) return;
|
||||
|
||||
if (height === 0) {
|
||||
hls.currentLevel = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const levelIndex = hls.levels.findIndex((level) => level.height === height);
|
||||
if (levelIndex >= 0) hls.currentLevel = levelIndex;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const PlyrModule = await import('plyr');
|
||||
const PlyrConstructor = PlyrModule.default;
|
||||
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
|
||||
|
||||
if (isYouTube && youtubeVideoId && containerElement) {
|
||||
const PlyrModule = await import('plyr');
|
||||
const PlyrConstructor = PlyrModule.default;
|
||||
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
|
||||
playerInstance = new PlyrConstructor(containerElement, {
|
||||
controls,
|
||||
autoplay,
|
||||
@@ -104,57 +272,202 @@
|
||||
});
|
||||
timeupdateCleanup = attachTimeUpdates(playerInstance);
|
||||
firstPlayCleanup = attachFirstPlay(playerInstance);
|
||||
plyrErrorCleanup = attachPlyrErrorHandler(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);
|
||||
// HLS sources defer Plyr until hls.js parses the manifest — Plyr's
|
||||
// quality menu must be configured at construction time, and tearing
|
||||
// the player down after hls.js attaches breaks the video wrapper.
|
||||
if (!hls && videoElement) {
|
||||
await constructPlyrForVideoElement();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
timeupdateCleanup?.();
|
||||
firstPlayCleanup?.();
|
||||
transcriptPanelCleanup?.();
|
||||
if (!playerInstance) return;
|
||||
|
||||
/**
|
||||
* Imperative poster setter. Plyr stores the poster as its own internal
|
||||
* property after construction, so updating `<video poster>` reactively
|
||||
* has no visible effect. Callers update the poster via this method.
|
||||
* Exposed through `bind:this` on `MediaPlayer`.
|
||||
*/
|
||||
export function setPoster(url: string): void {
|
||||
if (!playerInstance || isYouTube) return;
|
||||
try {
|
||||
if (playerInstance.isEmbed && playerInstance.playing) {
|
||||
playerInstance.pause();
|
||||
}
|
||||
playerInstance.poster = url;
|
||||
} catch {
|
||||
// No-op for player teardown safety.
|
||||
// Plyr may not have finished its setup yet — caller can retry.
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const element = videoElement;
|
||||
const nextSrc = src;
|
||||
if (!element || isYouTube || !nextSrc || nextSrc === loadedSrc) return;
|
||||
|
||||
loadedSrc = nextSrc;
|
||||
playbackFailed = false;
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
element.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
options.onSourceLoaded?.(element);
|
||||
};
|
||||
element.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
|
||||
if (hls) {
|
||||
void loadHlsSource(element, nextSrc, onLoadedMetadata);
|
||||
} else {
|
||||
element.src = nextSrc;
|
||||
element.load();
|
||||
}
|
||||
});
|
||||
|
||||
function destroyHlsInstance(): void {
|
||||
if (!hlsInstance) return;
|
||||
|
||||
try {
|
||||
playerInstance.destroy();
|
||||
hlsInstance.destroy();
|
||||
} catch {
|
||||
// ignore — hls.js may already be torn down
|
||||
} finally {
|
||||
playerInstance = null;
|
||||
hlsInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHlsSource(element: HTMLVideoElement, manifestUrl: string, onLoaded: () => void): Promise<void> {
|
||||
const loadGeneration = ++hlsLoadGeneration;
|
||||
hlsPlyrGeneration = 0;
|
||||
destroyHlsInstance();
|
||||
teardownPlyrInstance();
|
||||
playbackFailed = false;
|
||||
|
||||
if (options.onBeforeHlsLoad) {
|
||||
try {
|
||||
await options.onBeforeHlsLoad();
|
||||
} catch (error) {
|
||||
// The cookie endpoint may 503 in environments where HLS_SIGNING_SECRET
|
||||
// isn't configured (local dev / self-hosted). Playback can still
|
||||
// succeed via the API's session-auth streaming route — so we log and
|
||||
// proceed rather than fail.
|
||||
console.warn('onBeforeHlsLoad failed, continuing without cookie', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Bail if the source changed underneath us while awaiting the cookie.
|
||||
if (loadGeneration !== hlsLoadGeneration || loadedSrc !== manifestUrl) {
|
||||
element.removeEventListener('loadedmetadata', onLoaded);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer hls.js over native HLS. Chrome's `canPlayType` returns
|
||||
// `'maybe'` for `application/vnd.apple.mpegurl` even though it can't
|
||||
// actually play HLS — taking the native path on Chrome makes the
|
||||
// `<video>` element fetch the manifest directly with
|
||||
// `crossorigin="anonymous"`, which strips cookies and trips the api's
|
||||
// 403. Only fall back to native HLS when hls.js isn't supported
|
||||
// (real Safari + media-element-only environments).
|
||||
const HlsModule = await import('hls.js');
|
||||
const Hls = HlsModule.default;
|
||||
const FetchLoader = HlsModule.FetchLoader;
|
||||
|
||||
if (loadGeneration !== hlsLoadGeneration || loadedSrc !== manifestUrl) {
|
||||
element.removeEventListener('loadedmetadata', onLoaded);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Hls.isSupported()) {
|
||||
element.src = manifestUrl;
|
||||
element.load();
|
||||
void constructPlyrForVideoElement();
|
||||
return;
|
||||
}
|
||||
|
||||
hlsInstance = new Hls({
|
||||
enableWorker: true,
|
||||
// Defer segment fetches until the user presses play. Manifest/variant
|
||||
// playlist discovery still runs so duration metadata is available.
|
||||
autoStartLoad: false,
|
||||
// Use hls.js's fetch-based loader so we can set `credentials: 'include'`
|
||||
// uniformly on the manifest GET, variant playlist GETs, and segment
|
||||
// GETs. The default XhrLoader's `xhrSetup` doesn't always fire on
|
||||
// every request type (some bypass it for the master playlist),
|
||||
// leaving the request without cookies and the api returning 403.
|
||||
// FetchLoader respects standard Fetch API credentials.
|
||||
loader: FetchLoader,
|
||||
fetchSetup: (context, initParams) => {
|
||||
initParams.credentials = 'include';
|
||||
return new Request(context.url, initParams);
|
||||
}
|
||||
});
|
||||
const hls = hlsInstance;
|
||||
const plyrFallbackTimer = setTimeout(() => {
|
||||
if (loadGeneration !== hlsLoadGeneration || playerInstance) return;
|
||||
|
||||
void constructPlyrForVideoElement();
|
||||
}, 4_000);
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
handlePlaybackFailure();
|
||||
if (!playerInstance) {
|
||||
void constructPlyrForVideoElement();
|
||||
}
|
||||
}
|
||||
});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
clearTimeout(plyrFallbackTimer);
|
||||
void initHlsPlyr(hls, loadGeneration);
|
||||
});
|
||||
|
||||
const startBufferingOnPlay = () => {
|
||||
element.removeEventListener('play', startBufferingOnPlay);
|
||||
if (loadGeneration !== hlsLoadGeneration || hlsInstance !== hls) return;
|
||||
|
||||
hls.startLoad(-1);
|
||||
};
|
||||
element.addEventListener('play', startBufferingOnPlay);
|
||||
|
||||
hls.loadSource(manifestUrl);
|
||||
hls.attachMedia(element);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const player = playerInstance;
|
||||
const control = options.transcriptPanelControl;
|
||||
const template = transcriptButtonElement;
|
||||
if (!player || !control || !template) return;
|
||||
|
||||
transcriptPanelCleanup?.();
|
||||
transcriptPanelCleanup = attachTranscriptPanelControl(player, template, control);
|
||||
|
||||
return () => {
|
||||
transcriptPanelCleanup?.();
|
||||
transcriptPanelCleanup = undefined;
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
hlsLoadGeneration += 1;
|
||||
hlsPlyrGeneration = 0;
|
||||
destroyHlsInstance();
|
||||
|
||||
if (playerInstance) {
|
||||
try {
|
||||
if (playerInstance.isEmbed && playerInstance.playing) {
|
||||
playerInstance.pause();
|
||||
}
|
||||
} catch {
|
||||
// No-op for player teardown safety.
|
||||
}
|
||||
}
|
||||
|
||||
teardownPlyrInstance();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ui:relative ui:aspect-video ui:w-full ui:overflow-hidden ui:bg-muted"
|
||||
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};"
|
||||
>
|
||||
{#if isYouTube && youtubeVideoId}
|
||||
@@ -166,11 +479,15 @@
|
||||
></div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<!-- For HLS sources the $effect above assigns src (native HLS) or attaches hls.js; the bare element has no src here. -->
|
||||
<!-- `preload="metadata"` keeps the poster visible until the user clicks play. -->
|
||||
<!-- `crossorigin` is intentionally omitted: posters live on r2.dev (no CORS), Plyr loads its own `blank.mp4` from cdn.plyr.io (no CORS), and `<track>` works with `blob:` URLs (which the host app fetches via its API client and passes via the `tracks` prop). -->
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
{src}
|
||||
src={hls ? undefined : src}
|
||||
{poster}
|
||||
{playsinline}
|
||||
preload="metadata"
|
||||
class="plyr-player ui:h-full ui:w-full ui:object-contain"
|
||||
>
|
||||
{#each tracks as track (track.src + track.srclang)}
|
||||
@@ -185,6 +502,20 @@
|
||||
</video>
|
||||
{/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"
|
||||
role="alert"
|
||||
>
|
||||
<p class="ui:text-sm ui:text-muted-foreground">{options.playbackErrorLabel}</p>
|
||||
{#if options.onPlaybackReload && options.playbackReloadLabel}
|
||||
<Button variant="outline" disabled={reloadInFlight} onclick={() => void handlePlaybackReloadClick()}>
|
||||
{options.playbackReloadLabel}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if options.transcriptPanelControl}
|
||||
<button
|
||||
bind:this={transcriptButtonElement}
|
||||
|
||||
Reference in New Issue
Block a user