← All examples

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

live Plyr instances: 0 recreates: 0

Chapters

0 chapters — built in Svelte: plyr has no chapters UI.

playing paused ended 0:00 / 0:00 sound on

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.