Video player with themed controls and full keyboard support
Tags: component media display
Video replaces the browser's unstyleable native controls with a themed control bar, without giving up any playback capability. Every control is keyboard reachable, and the whole player responds to the shortcuts viewers expect from a video site.
Import
<script>
import { Video } from '@classic-homes/theme-svelte';
</script>Basic Usage
<Video
src="/walkthrough.mp4"
poster="/walkthrough.jpg"
label="Product walkthrough"
tracks={[{ src: '/walkthrough.en.vtt', srclang: 'en', label: 'English', default: true }]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
item |
VideoMediaItem |
- | Supplies src, poster, tracks, and the label in one object |
src |
string | MediaSource[] |
- | A single URL, or several sources for format fallback |
poster |
string |
- | Image shown before playback starts |
tracks |
MediaTextTrack[] |
[] |
Caption and subtitle tracks |
label |
string |
'Video player' |
Accessible name for the player region |
ratio |
'square' | 'video' | 'photo' | 'portrait' | 'wide' | number |
'video' |
Aspect ratio; pass null to size from the content |
fit |
'cover' | 'contain' | 'fill' | 'none' | 'scale-down' |
'contain' |
How the video fills its box |
autoplay |
boolean |
false |
Start playing automatically; implies muted |
muted |
boolean |
false |
Start muted |
loop |
boolean |
false |
Loop playback |
volume |
number |
1 |
Initial volume, 0 to 1 |
preload |
'none' | 'metadata' | 'auto' |
'metadata' |
How much to preload |
hideControls |
boolean |
false |
Hide the control bar entirely |
showPictureInPicture |
boolean |
true |
Show the picture-in-picture button |
showFullscreen |
boolean |
true |
Show the fullscreen button |
onEnded |
() => void |
- | Called when playback ends |
onTimeUpdate |
(currentTime: number) => void |
- | Called on each time update, with seconds elapsed |
hlsLoader |
() => Promise<HlsConstructor> |
- | Per-player hls.js loader, overriding the registered one |
hlsConfig |
Record<string, unknown> |
- | Extra hls.js configuration |
class |
string |
- | Additional CSS classes |
overlay |
Snippet |
- | Content overlaid on the video, above the controls |
Snippets
| Snippet | Description |
|---|---|
overlay |
Content layered over the video, above controls |
Keyboard Shortcuts
Active whenever focus is anywhere inside the player. Shortcuts are ignored while a text field has focus, and while a modifier key is held, so browser chords keep working.
| Key | Action |
|---|---|
Space or K |
Play/pause |
← / → |
Seek 5 seconds |
J / L |
Seek 10 seconds |
↑ / ↓ |
Volume up/down |
M |
Mute/unmute |
F |
Toggle fullscreen |
C |
Toggle captions |
Home / End |
Jump to start/end |
0–9 |
Seek to 0%–90% |
Multiple Sources
Give several sources to let the browser pick the first format it can decode:
<Video
src={[
{ src: '/clip.webm', type: 'video/webm' },
{ src: '/clip.mp4', type: 'video/mp4' },
]}
tracks={captions}
/>Captions
Pre-recorded video needs captions to meet WCAG 1.2.2. The component warns in development when tracks is empty, and the captions button only appears once at least one track exists.
<Video
src="/clip.mp4"
tracks={[
{ src: '/clip.en.vtt', srclang: 'en', label: 'English', default: true },
{ src: '/clip.es.vtt', srclang: 'es', label: 'Español' },
]}
/>Autoplay
Every major browser blocks autoplay with sound. Setting autoplay therefore mutes the video automatically, and a development warning fires if you also set muted={false}.
Adaptive Streaming (HLS)
Video plays HLS playlists with the same controls as a progressive file. Safari and iOS play HLS natively; every other browser needs hls.js (opens in new tab), which is an optional peer dependency.
Install it and register a loader once, at application startup:
npm install hls.js// src/routes/+layout.svelte, or wherever your app boots
import { setHlsLoader } from '@classic-homes/theme-svelte';
setHlsLoader(() => import('hls.js').then((m) => m.default));The import deliberately lives in your code rather than inside this package. A dynamic import('hls.js') written in the library would be resolved at build time by every consumer's bundler, so anyone who had not installed hls.js would fail to build even if they never played a stream.
A global Hls — as set by the library's CDN build in a <script> tag — is detected automatically and needs no registration. Individual players can also take an hlsLoader prop.
Without any of these, a browser that cannot play HLS natively shows an inline explanation rather than failing silently.
<Video src="https://example.com/playlist.m3u8" label="Live stream" />Sources are treated as HLS when they declare application/vnd.apple.mpegurl or end in .m3u8. Progressive sources listed alongside a playlist are kept as fallbacks.
preload="none" is honoured for streams too. hls.js downloads through its own loader and ignores the element's preload, so the player holds its loader back until the viewer presses play — without this, every stream on a page would pull segments the moment it mounted and starve the others of bandwidth. Pass hlsConfig={{ autoStartLoad: true }} to override.
Cloudflare Stream
cloudflareStream() builds a playable item from a Stream video id, deriving both the HLS playlist and a poster image from Stream's URL scheme — no API call required.
<script lang="ts">
import { Video, cloudflareStream } from '@classic-homes/theme-svelte';
const tour = cloudflareStream('a1b2c3d4e5f6', {
customerCode: 'abc123',
title: 'Product tour',
});
</script>
<Video item={tour} />| Option | Type | Default | Description |
|---|---|---|---|
customerCode |
string |
- | Customer code from the Stream dashboard; required unless domain |
domain |
string |
- | Custom hostname serving Stream |
title |
string |
- | Title used as the player's accessible name |
caption |
string |
- | Caption shown beneath the video |
poster |
string |
- | Override the derived poster |
posterTime |
string |
'1s' |
Timestamp the derived poster is taken from |
posterHeight |
number |
720 |
Height of the derived poster |
duration |
number |
- | Known duration, shown before metadata loads |
downloadable |
boolean |
false |
Expose the MP4 download URL (requires downloads enabled) |
lowLatency |
boolean |
false |
Request the low-latency HLS variant |
clientBandwidthHint |
number |
- | Bandwidth hint in Mbps |
Because Stream substitutes a signed playback token wherever the video id would appear, private videos need no separate code path — pass the token as the id:
const item = cloudflareStream(signedToken, { customerCode: 'abc123' });Stream embeds its captions in the playlist itself, so the captions button populates without a tracks prop, and the missing-captions development warning is suppressed for HLS sources.
For lower-level use, cloudflareStreamUrls() returns the hls, dash, iframe, thumbnail, and download URLs, and cloudflareStreamThumbnail() builds still or animated thumbnails with Stream's time, height, width, and fit parameters.
To embed Cloudflare's own player instead of these controls, use MediaEmbed with provider="cloudflare".
Accessibility
- The player is a labelled
regionand is focusable, so the shortcuts have somewhere to land. - Controls auto-hide only while playing; they stay visible when paused, hovered, or focused within, and hiding is disabled entirely under
prefers-reduced-motion. - The seek bar announces spoken timestamps (
1 minute 23 seconds of 4 minutes) rather than raw numbers. - Play, mute, captions, and fullscreen buttons all relabel themselves to match their current state.
Related Components
- Audio - The same player, laid out for audio
- MediaEmbed - YouTube and Vimeo without a self-hosted file
- Lightbox - Plays video full-screen
- MediaCarousel - Video among other media types