Plyr Video: Streaming & Captions
Plyr wrapped for Svelte 5: SSR-safe mounting, .destroy() lifecycle, $state-driven source/provider switching (HTML5 ×2 + YouTube embed), captions en⇄nb, quality/speed from state, sprite seek previews, plyr markers + a Svelte chapters sidebar (kind="chapters" VTTs, en⇄nb) — served over a Range-aware streaming route. Building or auditing an ecosystem video pipeline? Read src/lib/examples/plyr-video/README.md — "Pipeline requirements checklist" is the agent pointer: the 9-feature contract (playback, codec fallback, quality ladder, sprite previews, captions, chapters, markers, poster, Range/206 serving) plus the audit recipe.
One player, three providers
Chapters
0 chapters — built in Svelte: plyr has no chapters UI.
Media selector — $state drives the provider
6.25s TeamGym tumbling pass. h264/AAC 720p + 480p with VP9/Opus WebM twins — the browser picks the first source it can play (that IS the fallback mechanism). Captions: EN + NB. Sprite previews from the 160×90/0.5s sprite sheet. Plyr markers + a Svelte chapters sidebar (en⇄nb, kind="chapters" VTTs) — plyr itself has no chapters UI.
The switch goes through player.source = … — Plyr tears down the old media
element and builds the right one (video or YouTube iframe). Position is not preserved
across providers: different media, different timeline.
Svelte state → player API
Transport
Play here is a real user gesture — the green light for unmuted playback.
Captions (en ⇄ nb ⇄ off)
Sets Plyr currentTrack — the tracks come from the source’s tracks array (fleet i18n: en + nb).
Quality
Two-way with a vanilla library: your choice drives plyr’s setter, and plyr’s OWN choices (per-source defaults, its settings gear) are read back into this select — it always shows what is playing.
Speed
player.speed — works on HTML5 and YouTube alike.
Options change → destroy + recreate
Plyr exposes no public updateOptions(). Changing the options
object (here: captions active on load) rebuilds the player. The wrapper
preserves position and playback state across the swap — watch the time.
Player events → Svelte state
Interact with the player — events land here (timeupdate excluded as too noisy).
Why the player streams (HTTP Range)
206 on every seek
Open DevTools → Network while scrubbing: each jump fires GET /examples-media/… with Range: bytes=N- and comes
back 206 Partial Content with a Content-Range slice. Without it, every seek would re-download the file from byte 0.
ETag + If-Range
The route stamps an ETag (size-mtime). A Range request with an If-Range that no longer matches gets its Range IGNORED and the
full file re-streamed as 200 (RFC 9110 §13.1.5) — never a slice of a stale
copy. Matching validators still get 206; conditional GETs get 304.
The embed exception
Switch to the YouTube provider and watch the network tab: no Range requests — YouTube's CDN serves the bytes from its own URLs. Our streaming route is an HTML5-only story.
Markers vs chapters — two layers, one gap-filling pattern
Markers — plyr's own (coarse)
markers: { points: [{ time, label }] } ticks the progress
bar. The label belongs in the seek tooltip on hover — but with preview thumbnails
enabled plyr hides that tooltip (seekTooltip.hidden = true),
so stock plyr shows ticks with no labels. Our tiny bridge re-shows the themed
tooltip while a marker band is hovered. HTML5-only; rounded to whole seconds.
Chapters — ours (fine)
Plyr 3.8.4 has no chapters feature at all: a kind="chapters" track is filtered out of its caption tracks and
never rendered. So the sidebar is ours — fetch the VTT, parse the cues, render
the list, seek via player.currentTime, highlight from timeupdate. en⇄nb titles switch instantly (both files parsed up
front). HTML5-only.
The pattern — library gap → Svelte
Same doctrine as the streaming route: wrap what the library does well (playback, provider swaps, quality), build what it lacks from app code on top of its public bridge (state bindings + events). On embeds BOTH layers disappear — YouTube serves its own chapters from description timestamps, and plyr's HTML5 progress bar isn't there to tick.