2026-03-09 11:18:22 +01:00
< script lang = "ts" >
import { onDestroy , onMount } from 'svelte' ;
import 'plyr/dist/plyr.css' ;
2026-05-18 03:47:22 +01:00
import CaptionsIcon from '@lucide/svelte/icons/captions' ;
import type { MediaPlayerOptions , VideoTextTrack } from '../types' ;
2026-03-09 11:18:22 +01:00
import { getYoutubeVideoId , isYoutubeUrl } from '../utils' ;
import { PLYR_DEFAULT_CONTROLS } from './constants' ;
2026-06-07 18:43:05 +01:00
import { Button } from '../../../base/button' ;
2026-03-09 11:18:22 +01:00
import type Plyr from 'plyr' ;
interface Props {
src : string ;
poster? : string ;
2026-06-07 18:43:05 +01:00
/**
* 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 ;
2026-03-09 11:18:22 +01:00
options? : MediaPlayerOptions ;
2026-05-18 03:47:22 +01:00
tracks? : VideoTextTrack [];
2026-03-09 11:18:22 +01:00
}
2026-06-07 18:43:05 +01:00
let { src , poster , hls = false , options = {}, tracks = [] } : Props = $props ();
let hlsInstance = $state < import ( 'hls.js' ) .default | null >( null );
2026-03-09 11:18:22 +01:00
let videoElement = $state < HTMLVideoElement | null >( null );
let containerElement = $state < HTMLDivElement | null >( null );
let playerInstance = $state < Plyr | null >( null );
2026-05-18 03:47:22 +01:00
let transcriptButtonElement = $state < HTMLButtonElement | null >( null );
2026-06-07 18:43:05 +01:00
let playbackFailed = $state ( false );
let reloadInFlight = $state ( false );
2026-05-18 03:47:22 +01:00
let timeupdateCleanup : (() => void ) | undefined ;
let firstPlayCleanup : (() => void ) | undefined ;
let transcriptPanelCleanup : (() => void ) | undefined ;
2026-06-07 18:43:05 +01:00
let mediaErrorCleanup : (() => void ) | undefined ;
let plyrErrorCleanup : (() => void ) | undefined ;
let loadedSrc = $state < string | null >( null );
let hlsLoadGeneration = 0 ;
let hlsPlyrGeneration = 0 ;
let plyrConstructInFlight = false ;
2026-05-18 03:47:22 +01:00
let hasFiredFirstPlay = false ;
2026-03-09 11:18:22 +01:00
2026-06-07 18:43:05 +01:00
interface HlsQualityConfig {
default : number ;
options : number [];
forced : boolean ;
onChange : ( height : number ) => void ;
}
2026-03-09 11:18:22 +01:00
const playsinline = $derived ( options . playsinline !== false );
const maxHeight = $derived ( options . maxHeight || '400px' );
const showControls = $derived ( options . controls !== false );
const autoplay = $derived ( options . autoplay === true );
const isYouTube = $derived ( isYoutubeUrl ( src ));
const youtubeVideoId = $derived ( isYouTube ? getYoutubeVideoId ( src ) : null );
2026-05-18 03:47:22 +01:00
function attachTimeUpdates ( player : Plyr ) : () => void {
const handler = () => options . onTimeUpdate ? .( player . currentTime );
player . on ( 'timeupdate' , handler );
return () => {
player . off ( 'timeupdate' , handler );
};
}
2026-03-09 11:18:22 +01:00
2026-05-18 03:47:22 +01:00
function attachFirstPlay ( player : Plyr ) : () => void {
const handler = () => {
if ( hasFiredFirstPlay ) return ;
hasFiredFirstPlay = true ;
options . onFirstPlay ? .();
};
player . on ( 'play' , handler );
return () => {
player . off ( 'play' , handler );
};
}
2026-06-07 18:43:05 +01:00
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 );
};
}
2026-05-18 03:47:22 +01:00
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 );
2026-03-09 11:18:22 +01:00
}
2026-05-18 03:47:22 +01:00
return () => {
button . removeEventListener ( 'click' , onClick );
button . remove ();
};
}
2026-06-07 18:43:05 +01:00
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 ;
}
});
}
2026-05-18 03:47:22 +01:00
onMount (() => {
void ( async () => {
if ( isYouTube && youtubeVideoId && containerElement ) {
2026-06-07 18:43:05 +01:00
const PlyrModule = await import ( 'plyr' );
const PlyrConstructor = PlyrModule . default ;
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
2026-05-18 03:47:22 +01:00
playerInstance = new PlyrConstructor ( containerElement , {
controls ,
autoplay ,
ratio : '16:9' ,
iconUrl : '/plyr.svg' ,
iconPrefix : 'plyr'
});
timeupdateCleanup = attachTimeUpdates ( playerInstance );
firstPlayCleanup = attachFirstPlay ( playerInstance );
2026-06-07 18:43:05 +01:00
plyrErrorCleanup = attachPlyrErrorHandler ( playerInstance );
2026-05-18 03:47:22 +01:00
options . onPlayerReady ? .( playerInstance );
return ;
}
2026-06-07 18:43:05 +01:00
// 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 ();
2026-05-18 03:47:22 +01:00
}
})();
2026-03-09 11:18:22 +01:00
});
2026-06-07 18:43:05 +01:00
/**
* 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 ;
2026-03-09 11:18:22 +01:00
try {
2026-06-07 18:43:05 +01:00
playerInstance . poster = url ;
2026-03-09 11:18:22 +01:00
} catch {
2026-06-07 18:43:05 +01:00
// Plyr may not have finished its setup yet — caller can retry.
2026-03-09 11:18:22 +01:00
}
2026-06-07 18:43:05 +01:00
}
$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 ;
2026-03-09 11:18:22 +01:00
try {
2026-06-07 18:43:05 +01:00
hlsInstance . destroy ();
} catch {
// ignore — hls.js may already be torn down
2026-03-09 11:18:22 +01:00
} finally {
2026-06-07 18:43:05 +01:00
hlsInstance = null ;
2026-03-09 11:18:22 +01:00
}
2026-06-07 18:43:05 +01:00
}
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 ();
2026-03-09 11:18:22 +01:00
});
</ script >
2026-05-22 01:28:18 +01:00
< div
2026-06-07 18:43:05 +01:00
class = "ui:relative ui:aspect-video ui:w-full ui:overflow-hidden ui:rounded-md"
2026-05-22 01:28:18 +01:00
style = "max-height: { maxHeight } ; min-height: { options . minHeight } ; height: { options . height } ;"
>
2026-03-09 11:18:22 +01:00
{ #if isYouTube && youtubeVideoId }
< div
bind:this = { containerElement }
2026-05-23 12:24:57 +01:00
class="plyr__video-embed ui:h-full ui:w-full "
2026-03-09 11:18:22 +01:00
data-plyr-provider = "youtube"
data-plyr-embed-id = { youtubeVideoId }
> </div >
{ : else }
<!-- svelte-ignore a11y_media_has_caption -->
2026-06-07 18:43:05 +01:00
<!-- 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). -->
2026-05-22 01:28:18 +01:00
< video
bind:this = { videoElement }
2026-06-07 18:43:05 +01:00
src= { hls ? undefined : src }
2026-05-22 01:28:18 +01:00
{ poster }
{ playsinline }
2026-06-07 18:43:05 +01:00
preload = "metadata"
2026-05-23 12:24:57 +01:00
class = "plyr-player ui:h-full ui:w-full ui:object-contain"
2026-05-22 01:28:18 +01:00
>
2026-05-18 03:47:22 +01:00
{ #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 }
2026-06-07 18:43:05 +01:00
{ #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 }
2026-05-18 03:47:22 +01:00
{ #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 >
2026-03-09 11:18:22 +01:00
{ /if }
</ div >
2026-05-18 03:47:22 +01:00
< 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 : 18 px ;
height : 18 px ;
fill : none ;
stroke : currentColor ;
}
</ style >