@classic-homes/theme-svelte 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/dist/__tests__/a11y/Media.a11y.test.js +145 -0
- package/dist/__tests__/media-components.test.js +206 -0
- package/dist/__tests__/media-hls.test.js +273 -0
- package/dist/__tests__/media-matterport.test.js +296 -0
- package/dist/__tests__/media-players.test.js +244 -0
- package/dist/__tests__/media-scrubber.test.js +44 -0
- package/dist/__tests__/media-utils.test.js +427 -0
- package/dist/__tests__/setup.js +50 -0
- package/dist/lib/components/DataPanel.svelte +8 -0
- package/dist/lib/components/DataPanel.svelte.d.ts +6 -0
- package/dist/lib/components/DataPanelTab.svelte +38 -8
- package/dist/lib/components/DataPanelTab.svelte.d.ts +7 -0
- package/dist/lib/components/FileUpload.svelte +221 -4
- package/dist/lib/components/FileUpload.svelte.d.ts +13 -1
- package/dist/lib/components/media/AspectRatio.svelte +47 -0
- package/dist/lib/components/media/AspectRatio.svelte.d.ts +20 -0
- package/dist/lib/components/media/Audio.svelte +247 -0
- package/dist/lib/components/media/Audio.svelte.d.ts +34 -0
- package/dist/lib/components/media/Image.svelte +209 -0
- package/dist/lib/components/media/Image.svelte.d.ts +52 -0
- package/dist/lib/components/media/Lightbox.svelte +449 -0
- package/dist/lib/components/media/Lightbox.svelte.d.ts +33 -0
- package/dist/lib/components/media/MediaCarousel.svelte +359 -0
- package/dist/lib/components/media/MediaCarousel.svelte.d.ts +30 -0
- package/dist/lib/components/media/MediaEmbed.svelte +314 -0
- package/dist/lib/components/media/MediaEmbed.svelte.d.ts +99 -0
- package/dist/lib/components/media/MediaGrid.svelte +327 -0
- package/dist/lib/components/media/MediaGrid.svelte.d.ts +57 -0
- package/dist/lib/components/media/Video.svelte +455 -0
- package/dist/lib/components/media/Video.svelte.d.ts +81 -0
- package/dist/lib/components/media/index.d.ts +14 -0
- package/dist/lib/components/media/index.js +14 -0
- package/dist/lib/components/media/internal/MediaControls.svelte +283 -0
- package/dist/lib/components/media/internal/MediaControls.svelte.d.ts +32 -0
- package/dist/lib/components/media/internal/MediaScrubber.svelte +211 -0
- package/dist/lib/components/media/internal/MediaScrubber.svelte.d.ts +27 -0
- package/dist/lib/components/media/internal/MediaTile.svelte +129 -0
- package/dist/lib/components/media/internal/MediaTile.svelte.d.ts +22 -0
- package/dist/lib/composables/index.d.ts +3 -0
- package/dist/lib/composables/index.js +4 -0
- package/dist/lib/composables/useHlsSource.svelte.d.ts +70 -0
- package/dist/lib/composables/useHlsSource.svelte.js +119 -0
- package/dist/lib/composables/useMediaPlayer.svelte.d.ts +114 -0
- package/dist/lib/composables/useMediaPlayer.svelte.js +422 -0
- package/dist/lib/composables/useMediaZoom.svelte.d.ts +59 -0
- package/dist/lib/composables/useMediaZoom.svelte.js +199 -0
- package/dist/lib/index.d.ts +8 -2
- package/dist/lib/index.js +14 -1
- package/dist/lib/types/components.d.ts +172 -0
- package/dist/lib/utils/hls.d.ts +76 -0
- package/dist/lib/utils/hls.js +83 -0
- package/dist/lib/utils/media.d.ts +379 -0
- package/dist/lib/utils/media.js +715 -0
- package/package.json +6 -2
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Video - Video player with themed controls
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Custom control bar: play/pause, seek, volume, speed, captions, PiP, fullscreen
|
|
7
|
+
* - Full keyboard control (Space/K, arrows, J/L, M, F, C, Home/End, 0-9)
|
|
8
|
+
* - Controls auto-hide during playback and stay visible while paused or focused
|
|
9
|
+
* - Buffering spinner and an inline error state with retry
|
|
10
|
+
* - Honours `prefers-reduced-motion` by never fading the controls away
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```svelte
|
|
14
|
+
* <Video
|
|
15
|
+
* src="/clip.mp4"
|
|
16
|
+
* poster="/clip.jpg"
|
|
17
|
+
* label="Product walkthrough"
|
|
18
|
+
* tracks={[{ src: '/clip.en.vtt', srclang: 'en', label: 'English', default: true }]}
|
|
19
|
+
* />
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import type { Snippet } from 'svelte';
|
|
23
|
+
import { useHlsSource } from '../../composables/useHlsSource.svelte.js';
|
|
24
|
+
import { useMediaPlayer } from '../../composables/useMediaPlayer.svelte.js';
|
|
25
|
+
import { useReducedMotion } from '../../composables/useReducedMotion.svelte.js';
|
|
26
|
+
import type {
|
|
27
|
+
MediaAspectRatio,
|
|
28
|
+
MediaFit,
|
|
29
|
+
MediaSource,
|
|
30
|
+
MediaTextTrack,
|
|
31
|
+
VideoMediaItem,
|
|
32
|
+
} from '../../types/components.js';
|
|
33
|
+
import { cn } from '../../utils.js';
|
|
34
|
+
import type { HlsLoader } from '../../utils/hls.js';
|
|
35
|
+
import { isHlsSource, resolveAspectRatio, toMediaSources } from '../../utils/media.js';
|
|
36
|
+
import Icon from '../Icon.svelte';
|
|
37
|
+
import Spinner from '../Spinner.svelte';
|
|
38
|
+
import MediaControls from './internal/MediaControls.svelte';
|
|
39
|
+
|
|
40
|
+
/** How long the controls stay up after the last pointer movement */
|
|
41
|
+
const CONTROLS_HIDE_DELAY = 3000;
|
|
42
|
+
|
|
43
|
+
interface Props {
|
|
44
|
+
/**
|
|
45
|
+
* A video media item supplying `src`, `poster`, `tracks`, and the label.
|
|
46
|
+
* Individual props still win when both are given.
|
|
47
|
+
*
|
|
48
|
+
* Pairs with `cloudflareStream()` to play a Cloudflare Stream video.
|
|
49
|
+
*/
|
|
50
|
+
item?: VideoMediaItem;
|
|
51
|
+
/** A single URL, or several sources for format fallback */
|
|
52
|
+
src?: string | MediaSource[];
|
|
53
|
+
/** Poster image shown before playback starts */
|
|
54
|
+
poster?: string;
|
|
55
|
+
/** Caption and subtitle tracks — required for WCAG 1.2.2 on recorded content */
|
|
56
|
+
tracks?: MediaTextTrack[];
|
|
57
|
+
/** Accessible name for the player region */
|
|
58
|
+
label?: string;
|
|
59
|
+
/** Constrain the player to an aspect ratio (default `video`) */
|
|
60
|
+
ratio?: MediaAspectRatio | number | null;
|
|
61
|
+
/** How the video fills its box */
|
|
62
|
+
fit?: MediaFit;
|
|
63
|
+
/** Start playing automatically — requires `muted`, and browsers may still refuse */
|
|
64
|
+
autoplay?: boolean;
|
|
65
|
+
/** Start muted */
|
|
66
|
+
muted?: boolean;
|
|
67
|
+
/** Loop playback */
|
|
68
|
+
loop?: boolean;
|
|
69
|
+
/** Initial volume, 0–1 */
|
|
70
|
+
volume?: number;
|
|
71
|
+
/** How much to preload (default `metadata`, enough for duration without fetching the file) */
|
|
72
|
+
preload?: 'none' | 'metadata' | 'auto';
|
|
73
|
+
/** Hide the control bar entirely — the player is then keyboard-only */
|
|
74
|
+
hideControls?: boolean;
|
|
75
|
+
/** Show the picture-in-picture button */
|
|
76
|
+
showPictureInPicture?: boolean;
|
|
77
|
+
/** Show the fullscreen button */
|
|
78
|
+
showFullscreen?: boolean;
|
|
79
|
+
/** Called when playback ends */
|
|
80
|
+
onEnded?: () => void;
|
|
81
|
+
/** Called on each time update, with seconds elapsed */
|
|
82
|
+
onTimeUpdate?: (currentTime: number) => void;
|
|
83
|
+
/**
|
|
84
|
+
* How to load `hls.js` for this player, overriding any loader registered
|
|
85
|
+
* with `setHlsLoader`. Only consulted for HLS sources in browsers without
|
|
86
|
+
* native HLS support.
|
|
87
|
+
*
|
|
88
|
+
* @example hlsLoader={() => import('hls.js').then((m) => m.default)}
|
|
89
|
+
*/
|
|
90
|
+
hlsLoader?: HlsLoader;
|
|
91
|
+
/** Extra hls.js configuration */
|
|
92
|
+
hlsConfig?: Record<string, unknown>;
|
|
93
|
+
/** Content overlaid on the video, above the controls */
|
|
94
|
+
overlay?: Snippet;
|
|
95
|
+
/** Additional classes for the player container */
|
|
96
|
+
class?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let {
|
|
100
|
+
item,
|
|
101
|
+
src,
|
|
102
|
+
poster,
|
|
103
|
+
tracks,
|
|
104
|
+
label,
|
|
105
|
+
ratio = 'video',
|
|
106
|
+
fit = 'contain',
|
|
107
|
+
autoplay = false,
|
|
108
|
+
muted = false,
|
|
109
|
+
loop = false,
|
|
110
|
+
volume = 1,
|
|
111
|
+
preload = 'metadata',
|
|
112
|
+
hideControls = false,
|
|
113
|
+
showPictureInPicture = true,
|
|
114
|
+
showFullscreen = true,
|
|
115
|
+
onEnded,
|
|
116
|
+
onTimeUpdate,
|
|
117
|
+
hlsLoader,
|
|
118
|
+
hlsConfig,
|
|
119
|
+
overlay,
|
|
120
|
+
class: className,
|
|
121
|
+
}: Props = $props();
|
|
122
|
+
|
|
123
|
+
let videoElement = $state<HTMLVideoElement | null>(null);
|
|
124
|
+
let container = $state<HTMLDivElement | null>(null);
|
|
125
|
+
let pointerActive = $state(false);
|
|
126
|
+
let hideTimer: ReturnType<typeof setTimeout> | undefined;
|
|
127
|
+
|
|
128
|
+
// Explicit props win over the item, so a single field can be overridden
|
|
129
|
+
// without unpacking the whole thing.
|
|
130
|
+
const resolvedSrc = $derived(src ?? item?.src ?? []);
|
|
131
|
+
const resolvedPoster = $derived(poster ?? item?.poster);
|
|
132
|
+
const resolvedTracks = $derived(tracks ?? item?.tracks ?? []);
|
|
133
|
+
const resolvedLabel = $derived(label ?? item?.title ?? 'Video player');
|
|
134
|
+
|
|
135
|
+
const reducedMotion = useReducedMotion();
|
|
136
|
+
const player = useMediaPlayer(() => videoElement, {
|
|
137
|
+
volume: () => volume,
|
|
138
|
+
// Autoplay is only permitted while muted, so requesting it implies muting.
|
|
139
|
+
muted: () => muted || autoplay,
|
|
140
|
+
onEnded: () => onEnded?.(),
|
|
141
|
+
onTimeUpdate: (currentTime) => onTimeUpdate?.(currentTime),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const sources = $derived(toMediaSources(resolvedSrc));
|
|
145
|
+
const hlsSource = $derived(sources.find((source) => isHlsSource(source)) ?? null);
|
|
146
|
+
|
|
147
|
+
const hls = useHlsSource(() => videoElement, {
|
|
148
|
+
src: () => hlsSource?.src ?? null,
|
|
149
|
+
loader: () => hlsLoader,
|
|
150
|
+
config: () => hlsConfig,
|
|
151
|
+
// hls.js downloads through its own loader and ignores the element's
|
|
152
|
+
// `preload`, so mirror it here or a `preload="none"` stream would still
|
|
153
|
+
// pull segments the moment it mounts.
|
|
154
|
+
autoStartLoad: () => preload !== 'none',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/** Play, first releasing the stream loader when `preload="none"` held it back. */
|
|
158
|
+
function requestPlay() {
|
|
159
|
+
hls.startLoad();
|
|
160
|
+
return player.play();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function togglePlayback() {
|
|
164
|
+
if (player.paused) return requestPlay();
|
|
165
|
+
return player.toggle();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* An HLS playlist is only rendered as a `<source>` when the browser can
|
|
170
|
+
* play it natively. Everywhere else hls.js sets the element's source, and
|
|
171
|
+
* leaving a `<source>` the browser cannot decode would make it report an
|
|
172
|
+
* error before hls.js ever attaches.
|
|
173
|
+
*/
|
|
174
|
+
const elementSources = $derived(
|
|
175
|
+
hlsSource && !hls.native ? sources.filter((source) => !isHlsSource(source)) : sources
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
/** hls.js is required but unavailable, and there is no other playable source. */
|
|
179
|
+
const hlsUnavailable = $derived(
|
|
180
|
+
hlsSource !== null &&
|
|
181
|
+
(hls.status === 'unsupported' || hls.status === 'error') &&
|
|
182
|
+
elementSources.length === 0
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Controls stay up unless the video is actually playing and the pointer has
|
|
186
|
+
// gone idle. Keyboard users never lose them, because `focus-within` on the
|
|
187
|
+
// container keeps `pointerActive` irrelevant while focus is inside.
|
|
188
|
+
const controlsVisible = $derived(!player.playing || pointerActive || player.buffering);
|
|
189
|
+
|
|
190
|
+
const fitClass = $derived(
|
|
191
|
+
(
|
|
192
|
+
{
|
|
193
|
+
cover: 'object-cover',
|
|
194
|
+
contain: 'object-contain',
|
|
195
|
+
fill: 'object-fill',
|
|
196
|
+
none: 'object-none',
|
|
197
|
+
'scale-down': 'object-scale-down',
|
|
198
|
+
} as const
|
|
199
|
+
)[fit]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
function scheduleHide() {
|
|
203
|
+
clearTimeout(hideTimer);
|
|
204
|
+
pointerActive = true;
|
|
205
|
+
hideTimer = setTimeout(() => {
|
|
206
|
+
pointerActive = false;
|
|
207
|
+
}, CONTROLS_HIDE_DELAY);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function handlePointerLeave() {
|
|
211
|
+
clearTimeout(hideTimer);
|
|
212
|
+
pointerActive = false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
$effect(() => () => clearTimeout(hideTimer));
|
|
216
|
+
|
|
217
|
+
/** Typing in a field inside the overlay must not be swallowed as a shortcut. */
|
|
218
|
+
function isTextEntry(target: EventTarget | null): boolean {
|
|
219
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
220
|
+
return (
|
|
221
|
+
target.isContentEditable ||
|
|
222
|
+
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) ||
|
|
223
|
+
// The Bits UI slider thumb has its own arrow-key handling.
|
|
224
|
+
target.getAttribute('role') === 'slider'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
229
|
+
if (isTextEntry(event.target) || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
230
|
+
|
|
231
|
+
scheduleHide();
|
|
232
|
+
|
|
233
|
+
switch (event.key) {
|
|
234
|
+
case ' ':
|
|
235
|
+
case 'k':
|
|
236
|
+
case 'K':
|
|
237
|
+
event.preventDefault();
|
|
238
|
+
togglePlayback();
|
|
239
|
+
break;
|
|
240
|
+
case 'ArrowLeft':
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
player.seekBy(-5);
|
|
243
|
+
break;
|
|
244
|
+
case 'ArrowRight':
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
player.seekBy(5);
|
|
247
|
+
break;
|
|
248
|
+
case 'j':
|
|
249
|
+
case 'J':
|
|
250
|
+
event.preventDefault();
|
|
251
|
+
player.seekBy(-10);
|
|
252
|
+
break;
|
|
253
|
+
case 'l':
|
|
254
|
+
case 'L':
|
|
255
|
+
event.preventDefault();
|
|
256
|
+
player.seekBy(10);
|
|
257
|
+
break;
|
|
258
|
+
case 'ArrowUp':
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
player.adjustVolume(0.1);
|
|
261
|
+
break;
|
|
262
|
+
case 'ArrowDown':
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
player.adjustVolume(-0.1);
|
|
265
|
+
break;
|
|
266
|
+
case 'm':
|
|
267
|
+
case 'M':
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
player.toggleMute();
|
|
270
|
+
break;
|
|
271
|
+
case 'f':
|
|
272
|
+
case 'F':
|
|
273
|
+
if (!showFullscreen) return;
|
|
274
|
+
event.preventDefault();
|
|
275
|
+
player.toggleFullscreen(container);
|
|
276
|
+
break;
|
|
277
|
+
case 'c':
|
|
278
|
+
case 'C':
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
player.toggleCaptions();
|
|
281
|
+
break;
|
|
282
|
+
case 'Home':
|
|
283
|
+
event.preventDefault();
|
|
284
|
+
player.seek(0);
|
|
285
|
+
break;
|
|
286
|
+
case 'End':
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
player.seek(player.duration);
|
|
289
|
+
break;
|
|
290
|
+
default:
|
|
291
|
+
if (/^[0-9]$/.test(event.key)) {
|
|
292
|
+
event.preventDefault();
|
|
293
|
+
player.seekToFraction(Number(event.key) / 10);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function retry() {
|
|
299
|
+
videoElement?.load();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
303
|
+
$effect(() => {
|
|
304
|
+
// HLS carries its own subtitle tracks inside the playlist, so an empty
|
|
305
|
+
// `tracks` prop is expected and not a captions failure there.
|
|
306
|
+
if (resolvedTracks.length === 0 && !hlsSource) {
|
|
307
|
+
console.warn(
|
|
308
|
+
'Video: No caption tracks provided. Pre-recorded video needs captions to meet WCAG 1.2.2 — pass `tracks` with at least one WebVTT file.'
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (hls.status === 'unsupported') {
|
|
312
|
+
console.warn(
|
|
313
|
+
'Video: This browser cannot play HLS natively and no hls.js loader is available. Install hls.js and call setHlsLoader(() => import("hls.js").then((m) => m.default)) once at app startup, or pass an `hlsLoader` prop.'
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (autoplay && !muted) {
|
|
317
|
+
console.warn(
|
|
318
|
+
'Video: `autoplay` without `muted` is blocked by every major browser. Set `muted` as well, or drop `autoplay`.'
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
</script>
|
|
324
|
+
|
|
325
|
+
<!--
|
|
326
|
+
The container is the keyboard target and the fullscreen element. Player
|
|
327
|
+
shortcuts are bound here rather than on <video>, so they work while focus is
|
|
328
|
+
on any control inside.
|
|
329
|
+
-->
|
|
330
|
+
<!--
|
|
331
|
+
`tabindex="0"` is deliberate: the player region has to be reachable so the
|
|
332
|
+
playback shortcuts below work, which is the pattern the WAI media player
|
|
333
|
+
guidance calls for.
|
|
334
|
+
-->
|
|
335
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
336
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
337
|
+
<div
|
|
338
|
+
bind:this={container}
|
|
339
|
+
role="region"
|
|
340
|
+
aria-label={resolvedLabel}
|
|
341
|
+
tabindex="0"
|
|
342
|
+
class={cn(
|
|
343
|
+
'group/player relative isolate flex w-full flex-col overflow-hidden rounded-lg bg-black outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
|
344
|
+
className
|
|
345
|
+
)}
|
|
346
|
+
style={ratio ? `aspect-ratio: ${resolveAspectRatio(ratio)}` : undefined}
|
|
347
|
+
onkeydown={handleKeyDown}
|
|
348
|
+
onpointermove={scheduleHide}
|
|
349
|
+
onpointerdown={scheduleHide}
|
|
350
|
+
onpointerleave={handlePointerLeave}
|
|
351
|
+
>
|
|
352
|
+
<!-- svelte-ignore a11y_media_has_caption -- tracks are supplied via the `tracks` prop -->
|
|
353
|
+
<video
|
|
354
|
+
bind:this={videoElement}
|
|
355
|
+
class={cn('h-full w-full', fitClass)}
|
|
356
|
+
poster={resolvedPoster}
|
|
357
|
+
{autoplay}
|
|
358
|
+
{loop}
|
|
359
|
+
muted={muted || autoplay}
|
|
360
|
+
{preload}
|
|
361
|
+
playsinline
|
|
362
|
+
controls={false}
|
|
363
|
+
onclick={() => togglePlayback()}
|
|
364
|
+
>
|
|
365
|
+
{#each elementSources as source (source.src)}
|
|
366
|
+
<source src={source.src} type={source.type} />
|
|
367
|
+
{/each}
|
|
368
|
+
{#each resolvedTracks as track (track.src)}
|
|
369
|
+
<track
|
|
370
|
+
src={track.src}
|
|
371
|
+
kind={track.kind ?? 'subtitles'}
|
|
372
|
+
srclang={track.srclang}
|
|
373
|
+
label={track.label}
|
|
374
|
+
default={track.default}
|
|
375
|
+
/>
|
|
376
|
+
{/each}
|
|
377
|
+
Your browser does not support embedded video.
|
|
378
|
+
</video>
|
|
379
|
+
|
|
380
|
+
{#if hlsUnavailable}
|
|
381
|
+
<div
|
|
382
|
+
class="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-black/80 p-6 text-center text-white"
|
|
383
|
+
role="alert"
|
|
384
|
+
>
|
|
385
|
+
<Icon name="video" size={32} />
|
|
386
|
+
<p class="text-sm">This browser cannot play adaptive streams.</p>
|
|
387
|
+
<p class="max-w-sm text-xs text-white/70">
|
|
388
|
+
Playing HLS outside Safari needs hls.js. Install it and register a loader once at app
|
|
389
|
+
startup.
|
|
390
|
+
</p>
|
|
391
|
+
</div>
|
|
392
|
+
{:else if player.error}
|
|
393
|
+
<div
|
|
394
|
+
class="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/80 p-6 text-center text-white"
|
|
395
|
+
role="alert"
|
|
396
|
+
>
|
|
397
|
+
<Icon name="video" size={32} />
|
|
398
|
+
<p class="text-sm">This video could not be played.</p>
|
|
399
|
+
<button
|
|
400
|
+
type="button"
|
|
401
|
+
class="rounded-md bg-white/15 px-3 py-1.5 text-sm font-medium hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
|
402
|
+
onclick={retry}
|
|
403
|
+
>
|
|
404
|
+
Try again
|
|
405
|
+
</button>
|
|
406
|
+
</div>
|
|
407
|
+
{:else}
|
|
408
|
+
{#if player.buffering}
|
|
409
|
+
<div class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
|
410
|
+
<Spinner size="lg" class="text-white" />
|
|
411
|
+
</div>
|
|
412
|
+
{:else if !player.playing && (player.currentTime === 0 || player.ended)}
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
class="absolute inset-0 z-10 flex items-center justify-center bg-black/20 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
|
|
416
|
+
onclick={() => requestPlay()}
|
|
417
|
+
aria-label={player.ended ? 'Replay video' : 'Play video'}
|
|
418
|
+
>
|
|
419
|
+
<span
|
|
420
|
+
class="flex h-16 w-16 items-center justify-center rounded-full bg-black/60 text-white shadow-lg"
|
|
421
|
+
>
|
|
422
|
+
<Icon name="play" size={32} />
|
|
423
|
+
</span>
|
|
424
|
+
</button>
|
|
425
|
+
{/if}
|
|
426
|
+
|
|
427
|
+
{#if overlay}
|
|
428
|
+
{@render overlay()}
|
|
429
|
+
{/if}
|
|
430
|
+
|
|
431
|
+
{#if !hideControls}
|
|
432
|
+
<div
|
|
433
|
+
class={cn(
|
|
434
|
+
'absolute inset-x-0 bottom-0 z-20',
|
|
435
|
+
// Reduced motion keeps the bar permanently visible rather than fading it.
|
|
436
|
+
reducedMotion.value
|
|
437
|
+
? ''
|
|
438
|
+
: cn(
|
|
439
|
+
'transition-opacity duration-200 focus-within:opacity-100',
|
|
440
|
+
controlsVisible ? 'opacity-100' : 'opacity-0'
|
|
441
|
+
)
|
|
442
|
+
)}
|
|
443
|
+
>
|
|
444
|
+
<MediaControls
|
|
445
|
+
{player}
|
|
446
|
+
variant="video"
|
|
447
|
+
{showPictureInPicture}
|
|
448
|
+
{showFullscreen}
|
|
449
|
+
fullscreenTarget={container}
|
|
450
|
+
onTogglePlay={togglePlayback}
|
|
451
|
+
/>
|
|
452
|
+
</div>
|
|
453
|
+
{/if}
|
|
454
|
+
{/if}
|
|
455
|
+
</div>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video - Video player with themed controls
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Custom control bar: play/pause, seek, volume, speed, captions, PiP, fullscreen
|
|
6
|
+
* - Full keyboard control (Space/K, arrows, J/L, M, F, C, Home/End, 0-9)
|
|
7
|
+
* - Controls auto-hide during playback and stay visible while paused or focused
|
|
8
|
+
* - Buffering spinner and an inline error state with retry
|
|
9
|
+
* - Honours `prefers-reduced-motion` by never fading the controls away
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```svelte
|
|
13
|
+
* <Video
|
|
14
|
+
* src="/clip.mp4"
|
|
15
|
+
* poster="/clip.jpg"
|
|
16
|
+
* label="Product walkthrough"
|
|
17
|
+
* tracks={[{ src: '/clip.en.vtt', srclang: 'en', label: 'English', default: true }]}
|
|
18
|
+
* />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import type { Snippet } from 'svelte';
|
|
22
|
+
import type { MediaAspectRatio, MediaFit, MediaSource, MediaTextTrack, VideoMediaItem } from '../../types/components.js';
|
|
23
|
+
import type { HlsLoader } from '../../utils/hls.js';
|
|
24
|
+
interface Props {
|
|
25
|
+
/**
|
|
26
|
+
* A video media item supplying `src`, `poster`, `tracks`, and the label.
|
|
27
|
+
* Individual props still win when both are given.
|
|
28
|
+
*
|
|
29
|
+
* Pairs with `cloudflareStream()` to play a Cloudflare Stream video.
|
|
30
|
+
*/
|
|
31
|
+
item?: VideoMediaItem;
|
|
32
|
+
/** A single URL, or several sources for format fallback */
|
|
33
|
+
src?: string | MediaSource[];
|
|
34
|
+
/** Poster image shown before playback starts */
|
|
35
|
+
poster?: string;
|
|
36
|
+
/** Caption and subtitle tracks — required for WCAG 1.2.2 on recorded content */
|
|
37
|
+
tracks?: MediaTextTrack[];
|
|
38
|
+
/** Accessible name for the player region */
|
|
39
|
+
label?: string;
|
|
40
|
+
/** Constrain the player to an aspect ratio (default `video`) */
|
|
41
|
+
ratio?: MediaAspectRatio | number | null;
|
|
42
|
+
/** How the video fills its box */
|
|
43
|
+
fit?: MediaFit;
|
|
44
|
+
/** Start playing automatically — requires `muted`, and browsers may still refuse */
|
|
45
|
+
autoplay?: boolean;
|
|
46
|
+
/** Start muted */
|
|
47
|
+
muted?: boolean;
|
|
48
|
+
/** Loop playback */
|
|
49
|
+
loop?: boolean;
|
|
50
|
+
/** Initial volume, 0–1 */
|
|
51
|
+
volume?: number;
|
|
52
|
+
/** How much to preload (default `metadata`, enough for duration without fetching the file) */
|
|
53
|
+
preload?: 'none' | 'metadata' | 'auto';
|
|
54
|
+
/** Hide the control bar entirely — the player is then keyboard-only */
|
|
55
|
+
hideControls?: boolean;
|
|
56
|
+
/** Show the picture-in-picture button */
|
|
57
|
+
showPictureInPicture?: boolean;
|
|
58
|
+
/** Show the fullscreen button */
|
|
59
|
+
showFullscreen?: boolean;
|
|
60
|
+
/** Called when playback ends */
|
|
61
|
+
onEnded?: () => void;
|
|
62
|
+
/** Called on each time update, with seconds elapsed */
|
|
63
|
+
onTimeUpdate?: (currentTime: number) => void;
|
|
64
|
+
/**
|
|
65
|
+
* How to load `hls.js` for this player, overriding any loader registered
|
|
66
|
+
* with `setHlsLoader`. Only consulted for HLS sources in browsers without
|
|
67
|
+
* native HLS support.
|
|
68
|
+
*
|
|
69
|
+
* @example hlsLoader={() => import('hls.js').then((m) => m.default)}
|
|
70
|
+
*/
|
|
71
|
+
hlsLoader?: HlsLoader;
|
|
72
|
+
/** Extra hls.js configuration */
|
|
73
|
+
hlsConfig?: Record<string, unknown>;
|
|
74
|
+
/** Content overlaid on the video, above the controls */
|
|
75
|
+
overlay?: Snippet;
|
|
76
|
+
/** Additional classes for the player container */
|
|
77
|
+
class?: string;
|
|
78
|
+
}
|
|
79
|
+
declare const Video: import("svelte").Component<Props, {}, "">;
|
|
80
|
+
type Video = ReturnType<typeof Video>;
|
|
81
|
+
export default Video;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Components
|
|
3
|
+
*
|
|
4
|
+
* Presentation components for images, video, audio, and third-party embeds,
|
|
5
|
+
* plus collection views and a full-screen lightbox.
|
|
6
|
+
*/
|
|
7
|
+
export { default as AspectRatio } from './AspectRatio.svelte';
|
|
8
|
+
export { default as Image } from './Image.svelte';
|
|
9
|
+
export { default as Video } from './Video.svelte';
|
|
10
|
+
export { default as Audio } from './Audio.svelte';
|
|
11
|
+
export { default as MediaEmbed } from './MediaEmbed.svelte';
|
|
12
|
+
export { default as MediaGrid } from './MediaGrid.svelte';
|
|
13
|
+
export { default as MediaCarousel } from './MediaCarousel.svelte';
|
|
14
|
+
export { default as Lightbox } from './Lightbox.svelte';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Components
|
|
3
|
+
*
|
|
4
|
+
* Presentation components for images, video, audio, and third-party embeds,
|
|
5
|
+
* plus collection views and a full-screen lightbox.
|
|
6
|
+
*/
|
|
7
|
+
export { default as AspectRatio } from './AspectRatio.svelte';
|
|
8
|
+
export { default as Image } from './Image.svelte';
|
|
9
|
+
export { default as Video } from './Video.svelte';
|
|
10
|
+
export { default as Audio } from './Audio.svelte';
|
|
11
|
+
export { default as MediaEmbed } from './MediaEmbed.svelte';
|
|
12
|
+
export { default as MediaGrid } from './MediaGrid.svelte';
|
|
13
|
+
export { default as MediaCarousel } from './MediaCarousel.svelte';
|
|
14
|
+
export { default as Lightbox } from './Lightbox.svelte';
|