@classic-homes/theme-svelte 1.2.3 → 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/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,359 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* MediaCarousel - Horizontally scrolling media slides
|
|
4
|
+
*
|
|
5
|
+
* Built on native CSS scroll-snap, so touch swiping, momentum, and
|
|
6
|
+
* scrollbar behaviour come from the browser. No pointer capture is used, so
|
|
7
|
+
* controls layered over the track stay clickable.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Previous/next buttons, dot indicators, and an optional thumbnail strip
|
|
11
|
+
* - Arrow, Home, and End keys move between slides
|
|
12
|
+
* - Optional autoplay with a visible play/pause control (WCAG 2.2.2), paused
|
|
13
|
+
* on hover, on focus, and under `prefers-reduced-motion`
|
|
14
|
+
* - Announces the current slide to assistive technology as it changes
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```svelte
|
|
18
|
+
* <MediaCarousel items={photos} label="Project gallery" showThumbnails />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import { useReducedMotion } from '../../composables/useReducedMotion.svelte.js';
|
|
22
|
+
import type { MediaAspectRatio, MediaItem } from '../../types/components.js';
|
|
23
|
+
import { cn } from '../../utils.js';
|
|
24
|
+
import { getMediaLabel } from '../../utils/media.js';
|
|
25
|
+
import Icon from '../Icon.svelte';
|
|
26
|
+
import LiveRegion from '../LiveRegion.svelte';
|
|
27
|
+
import Audio from './Audio.svelte';
|
|
28
|
+
import Image from './Image.svelte';
|
|
29
|
+
import MediaEmbed from './MediaEmbed.svelte';
|
|
30
|
+
import Video from './Video.svelte';
|
|
31
|
+
import MediaTile from './internal/MediaTile.svelte';
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
/** Slides to display */
|
|
35
|
+
items: MediaItem[];
|
|
36
|
+
/** Accessible name for the carousel */
|
|
37
|
+
label?: string;
|
|
38
|
+
/** Current slide index (bindable) */
|
|
39
|
+
index?: number;
|
|
40
|
+
/** Aspect ratio of each slide */
|
|
41
|
+
ratio?: MediaAspectRatio | number;
|
|
42
|
+
/** Wrap from the last slide back to the first */
|
|
43
|
+
loop?: boolean;
|
|
44
|
+
/** Show dot indicators below the track */
|
|
45
|
+
showDots?: boolean;
|
|
46
|
+
/** Show a thumbnail strip below the track (replaces the dots) */
|
|
47
|
+
showThumbnails?: boolean;
|
|
48
|
+
/** Show captions beneath the active slide */
|
|
49
|
+
showCaption?: boolean;
|
|
50
|
+
/** Advance automatically. Always paired with a visible pause control. */
|
|
51
|
+
autoplay?: boolean;
|
|
52
|
+
/** Milliseconds between automatic advances */
|
|
53
|
+
autoplayInterval?: number;
|
|
54
|
+
/** Called when the active slide changes */
|
|
55
|
+
onIndexChange?: (index: number) => void;
|
|
56
|
+
/** Additional classes */
|
|
57
|
+
class?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let {
|
|
61
|
+
items,
|
|
62
|
+
label = 'Media carousel',
|
|
63
|
+
index = $bindable(0),
|
|
64
|
+
ratio = 'video',
|
|
65
|
+
loop = false,
|
|
66
|
+
showDots = true,
|
|
67
|
+
showThumbnails = false,
|
|
68
|
+
showCaption = true,
|
|
69
|
+
autoplay = false,
|
|
70
|
+
autoplayInterval = 5000,
|
|
71
|
+
onIndexChange,
|
|
72
|
+
class: className,
|
|
73
|
+
}: Props = $props();
|
|
74
|
+
|
|
75
|
+
let trackElement = $state<HTMLElement | null>(null);
|
|
76
|
+
/** Set when the viewer explicitly stops the slideshow, separate from `autoplay`. */
|
|
77
|
+
let userPaused = $state(false);
|
|
78
|
+
let interacting = $state(false);
|
|
79
|
+
let scrollSyncTimer: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
let autoplayTimer: ReturnType<typeof setInterval> | undefined;
|
|
81
|
+
|
|
82
|
+
const reducedMotion = useReducedMotion();
|
|
83
|
+
|
|
84
|
+
const activeItem = $derived(items[index]);
|
|
85
|
+
const canGoPrevious = $derived(loop || index > 0);
|
|
86
|
+
const canGoNext = $derived(loop || index < items.length - 1);
|
|
87
|
+
const positionLabel = $derived(`${index + 1} of ${items.length}`);
|
|
88
|
+
|
|
89
|
+
const buttonClass =
|
|
90
|
+
'inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background/90 text-foreground shadow-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40';
|
|
91
|
+
|
|
92
|
+
function goTo(next: number, smooth = true) {
|
|
93
|
+
if (items.length === 0) return;
|
|
94
|
+
|
|
95
|
+
let target = next;
|
|
96
|
+
if (target < 0) target = loop ? items.length - 1 : 0;
|
|
97
|
+
if (target >= items.length) target = loop ? 0 : items.length - 1;
|
|
98
|
+
if (target === index) return;
|
|
99
|
+
|
|
100
|
+
index = target;
|
|
101
|
+
onIndexChange?.(target);
|
|
102
|
+
scrollToSlide(target, smooth);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scrollToSlide(target: number, smooth: boolean) {
|
|
106
|
+
if (!trackElement) return;
|
|
107
|
+
const slide = trackElement.children[target] as HTMLElement | undefined;
|
|
108
|
+
if (!slide) return;
|
|
109
|
+
trackElement.scrollTo({
|
|
110
|
+
left: slide.offsetLeft - trackElement.offsetLeft,
|
|
111
|
+
behavior: smooth && !reducedMotion.value ? 'smooth' : 'auto',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Derive the active index from the scroll position, so swiping and
|
|
117
|
+
* scrollbar dragging stay in sync with the controls. Debounced, because
|
|
118
|
+
* `scroll` fires continuously through a smooth scroll.
|
|
119
|
+
*/
|
|
120
|
+
function handleScroll() {
|
|
121
|
+
clearTimeout(scrollSyncTimer);
|
|
122
|
+
scrollSyncTimer = setTimeout(() => {
|
|
123
|
+
if (!trackElement) return;
|
|
124
|
+
const { scrollLeft, clientWidth } = trackElement;
|
|
125
|
+
const centre = scrollLeft + clientWidth / 2;
|
|
126
|
+
|
|
127
|
+
let closest = 0;
|
|
128
|
+
let closestDistance = Number.POSITIVE_INFINITY;
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < trackElement.children.length; i += 1) {
|
|
131
|
+
const slide = trackElement.children[i] as HTMLElement;
|
|
132
|
+
const slideCentre = slide.offsetLeft + slide.clientWidth / 2;
|
|
133
|
+
const distance = Math.abs(slideCentre - centre);
|
|
134
|
+
if (distance < closestDistance) {
|
|
135
|
+
closestDistance = distance;
|
|
136
|
+
closest = i;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (closest !== index) {
|
|
141
|
+
index = closest;
|
|
142
|
+
onIndexChange?.(closest);
|
|
143
|
+
}
|
|
144
|
+
}, 100);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
148
|
+
switch (event.key) {
|
|
149
|
+
case 'ArrowLeft':
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
goTo(index - 1);
|
|
152
|
+
break;
|
|
153
|
+
case 'ArrowRight':
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
goTo(index + 1);
|
|
156
|
+
break;
|
|
157
|
+
case 'Home':
|
|
158
|
+
event.preventDefault();
|
|
159
|
+
goTo(0);
|
|
160
|
+
break;
|
|
161
|
+
case 'End':
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
goTo(items.length - 1);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Autoplay halts while the viewer is hovering or has focus inside, and never
|
|
169
|
+
// starts at all when reduced motion is requested.
|
|
170
|
+
const autoplayRunning = $derived(
|
|
171
|
+
autoplay && !userPaused && !interacting && !reducedMotion.value && items.length > 1
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
$effect(() => {
|
|
175
|
+
if (!autoplayRunning) return;
|
|
176
|
+
|
|
177
|
+
autoplayTimer = setInterval(() => {
|
|
178
|
+
const next = index + 1;
|
|
179
|
+
goTo(next >= items.length ? 0 : next);
|
|
180
|
+
}, autoplayInterval);
|
|
181
|
+
|
|
182
|
+
return () => clearInterval(autoplayTimer);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
$effect(() => () => clearTimeout(scrollSyncTimer));
|
|
186
|
+
</script>
|
|
187
|
+
|
|
188
|
+
<section
|
|
189
|
+
aria-roledescription="carousel"
|
|
190
|
+
aria-label={label}
|
|
191
|
+
class={cn('flex w-full flex-col gap-3', className)}
|
|
192
|
+
onpointerenter={() => (interacting = true)}
|
|
193
|
+
onpointerleave={() => (interacting = false)}
|
|
194
|
+
onfocusin={() => (interacting = true)}
|
|
195
|
+
onfocusout={() => (interacting = false)}
|
|
196
|
+
>
|
|
197
|
+
<div class="relative">
|
|
198
|
+
<!--
|
|
199
|
+
The track itself is the keyboard target for slide navigation. Native
|
|
200
|
+
scrolling provides touch swipe, so no pointer capture is involved and
|
|
201
|
+
the overlaid buttons keep receiving clicks.
|
|
202
|
+
-->
|
|
203
|
+
<!--
|
|
204
|
+
Slides carry `role="group"`, which list markup does not permit, so the
|
|
205
|
+
track is a plain container rather than a <ul> — this is the structure the
|
|
206
|
+
APG carousel pattern describes.
|
|
207
|
+
-->
|
|
208
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
209
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
210
|
+
<div
|
|
211
|
+
bind:this={trackElement}
|
|
212
|
+
tabindex="0"
|
|
213
|
+
aria-label="{label} slides"
|
|
214
|
+
class="flex snap-x snap-mandatory overflow-x-auto scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
215
|
+
onscroll={handleScroll}
|
|
216
|
+
onkeydown={handleKeyDown}
|
|
217
|
+
>
|
|
218
|
+
{#each items as item, slideIndex (item.id)}
|
|
219
|
+
<div
|
|
220
|
+
role="group"
|
|
221
|
+
aria-roledescription="slide"
|
|
222
|
+
aria-label="{slideIndex + 1} of {items.length}"
|
|
223
|
+
aria-hidden={slideIndex !== index ? 'true' : undefined}
|
|
224
|
+
class="w-full shrink-0 snap-center"
|
|
225
|
+
>
|
|
226
|
+
{#if item.kind === 'image'}
|
|
227
|
+
<Image
|
|
228
|
+
src={item.src}
|
|
229
|
+
alt={item.alt}
|
|
230
|
+
srcset={item.srcset}
|
|
231
|
+
sizes={item.sizes}
|
|
232
|
+
placeholder={item.placeholder}
|
|
233
|
+
{ratio}
|
|
234
|
+
fit="cover"
|
|
235
|
+
rounded="lg"
|
|
236
|
+
class="w-full"
|
|
237
|
+
/>
|
|
238
|
+
{:else if item.kind === 'video'}
|
|
239
|
+
<Video
|
|
240
|
+
src={item.src}
|
|
241
|
+
poster={item.poster}
|
|
242
|
+
tracks={item.tracks}
|
|
243
|
+
label={getMediaLabel(item, slideIndex)}
|
|
244
|
+
{ratio}
|
|
245
|
+
/>
|
|
246
|
+
{:else if item.kind === 'audio'}
|
|
247
|
+
<div class="flex h-full w-full items-center px-1">
|
|
248
|
+
<Audio
|
|
249
|
+
src={item.src}
|
|
250
|
+
title={item.title}
|
|
251
|
+
artist={item.artist}
|
|
252
|
+
artwork={item.artwork}
|
|
253
|
+
tracks={item.tracks}
|
|
254
|
+
class="w-full"
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
{:else}
|
|
258
|
+
<MediaEmbed
|
|
259
|
+
provider={item.provider}
|
|
260
|
+
videoId={item.videoId}
|
|
261
|
+
poster={item.poster}
|
|
262
|
+
start={item.start}
|
|
263
|
+
customerCode={item.customerCode}
|
|
264
|
+
domain={item.domain}
|
|
265
|
+
matterport={item.matterport}
|
|
266
|
+
title={getMediaLabel(item, slideIndex)}
|
|
267
|
+
{ratio}
|
|
268
|
+
class="rounded-lg"
|
|
269
|
+
/>
|
|
270
|
+
{/if}
|
|
271
|
+
</div>
|
|
272
|
+
{/each}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{#if items.length > 1}
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
class={cn(buttonClass, 'absolute left-2 top-1/2 -translate-y-1/2')}
|
|
279
|
+
onclick={() => goTo(index - 1)}
|
|
280
|
+
disabled={!canGoPrevious}
|
|
281
|
+
aria-label="Previous slide"
|
|
282
|
+
>
|
|
283
|
+
<Icon name="chevron-left" size={20} />
|
|
284
|
+
</button>
|
|
285
|
+
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
class={cn(buttonClass, 'absolute right-2 top-1/2 -translate-y-1/2')}
|
|
289
|
+
onclick={() => goTo(index + 1)}
|
|
290
|
+
disabled={!canGoNext}
|
|
291
|
+
aria-label="Next slide"
|
|
292
|
+
>
|
|
293
|
+
<Icon name="chevron-right" size={20} />
|
|
294
|
+
</button>
|
|
295
|
+
{/if}
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{#if showCaption && activeItem?.caption}
|
|
299
|
+
<p class="text-sm text-muted-foreground">{activeItem.caption}</p>
|
|
300
|
+
{/if}
|
|
301
|
+
|
|
302
|
+
<div class="flex items-center gap-3">
|
|
303
|
+
{#if autoplay}
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
307
|
+
onclick={() => (userPaused = !userPaused)}
|
|
308
|
+
aria-label={userPaused ? 'Start automatic slideshow' : 'Stop automatic slideshow'}
|
|
309
|
+
>
|
|
310
|
+
<Icon name={userPaused ? 'play' : 'pause'} size={16} />
|
|
311
|
+
</button>
|
|
312
|
+
{/if}
|
|
313
|
+
|
|
314
|
+
{#if showThumbnails}
|
|
315
|
+
<!--
|
|
316
|
+
`p-1` leaves room for the 4px selection and focus rings, which paint
|
|
317
|
+
outside each tile's border box. Without it they are clipped: setting
|
|
318
|
+
`overflow-x` makes `overflow-y` compute to `auto` as well, so the strip
|
|
319
|
+
crops anything drawn beyond its edges.
|
|
320
|
+
-->
|
|
321
|
+
<ul class="flex flex-1 list-none gap-2 overflow-x-auto p-1" aria-label="{label} thumbnails">
|
|
322
|
+
{#each items as item, slideIndex (item.id)}
|
|
323
|
+
<li class="w-16 shrink-0">
|
|
324
|
+
<MediaTile
|
|
325
|
+
{item}
|
|
326
|
+
index={slideIndex}
|
|
327
|
+
ratio="square"
|
|
328
|
+
selected={slideIndex === index}
|
|
329
|
+
onselect={(i) => goTo(i)}
|
|
330
|
+
/>
|
|
331
|
+
</li>
|
|
332
|
+
{/each}
|
|
333
|
+
</ul>
|
|
334
|
+
{:else if showDots && items.length > 1}
|
|
335
|
+
<div class="flex flex-1 items-center justify-center gap-2">
|
|
336
|
+
{#each items as item, slideIndex (item.id)}
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
class={cn(
|
|
340
|
+
'h-2 rounded-full outline-none transition-all focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
|
341
|
+
slideIndex === index ? 'w-6 bg-primary' : 'w-2 bg-muted-foreground/40'
|
|
342
|
+
)}
|
|
343
|
+
onclick={() => goTo(slideIndex)}
|
|
344
|
+
aria-label="Go to slide {slideIndex + 1}: {getMediaLabel(item, slideIndex)}"
|
|
345
|
+
aria-current={slideIndex === index ? 'true' : undefined}
|
|
346
|
+
></button>
|
|
347
|
+
{/each}
|
|
348
|
+
</div>
|
|
349
|
+
{/if}
|
|
350
|
+
|
|
351
|
+
<span class="shrink-0 text-xs tabular-nums text-muted-foreground" aria-hidden="true">
|
|
352
|
+
{positionLabel}
|
|
353
|
+
</span>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
<LiveRegion
|
|
357
|
+
message={activeItem ? `Slide ${positionLabel}: ${getMediaLabel(activeItem, index)}` : ''}
|
|
358
|
+
/>
|
|
359
|
+
</section>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MediaAspectRatio, MediaItem } from '../../types/components.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Slides to display */
|
|
4
|
+
items: MediaItem[];
|
|
5
|
+
/** Accessible name for the carousel */
|
|
6
|
+
label?: string;
|
|
7
|
+
/** Current slide index (bindable) */
|
|
8
|
+
index?: number;
|
|
9
|
+
/** Aspect ratio of each slide */
|
|
10
|
+
ratio?: MediaAspectRatio | number;
|
|
11
|
+
/** Wrap from the last slide back to the first */
|
|
12
|
+
loop?: boolean;
|
|
13
|
+
/** Show dot indicators below the track */
|
|
14
|
+
showDots?: boolean;
|
|
15
|
+
/** Show a thumbnail strip below the track (replaces the dots) */
|
|
16
|
+
showThumbnails?: boolean;
|
|
17
|
+
/** Show captions beneath the active slide */
|
|
18
|
+
showCaption?: boolean;
|
|
19
|
+
/** Advance automatically. Always paired with a visible pause control. */
|
|
20
|
+
autoplay?: boolean;
|
|
21
|
+
/** Milliseconds between automatic advances */
|
|
22
|
+
autoplayInterval?: number;
|
|
23
|
+
/** Called when the active slide changes */
|
|
24
|
+
onIndexChange?: (index: number) => void;
|
|
25
|
+
/** Additional classes */
|
|
26
|
+
class?: string;
|
|
27
|
+
}
|
|
28
|
+
declare const MediaCarousel: import("svelte").Component<Props, {}, "index">;
|
|
29
|
+
type MediaCarousel = ReturnType<typeof MediaCarousel>;
|
|
30
|
+
export default MediaCarousel;
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* MediaEmbed - Lazy YouTube, Vimeo, Cloudflare Stream, and Matterport embeds
|
|
4
|
+
*
|
|
5
|
+
* Renders a poster facade and only injects the provider's iframe once the
|
|
6
|
+
* viewer clicks, so the page loads no third-party player scripts and sets no
|
|
7
|
+
* tracking cookies on arrival.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Accepts a full watch URL, or an explicit provider + id
|
|
11
|
+
* - YouTube routes through `youtube-nocookie.com` by default
|
|
12
|
+
* - YouTube and Cloudflare Stream posters are derived; Vimeo needs `poster`
|
|
13
|
+
* - Matterport publishes no thumbnail: pass `poster`, or opt into
|
|
14
|
+
* `fetchMetadata` to look one up at runtime
|
|
15
|
+
* - A Matterport space is spatial rather than timed, so its facade offers to
|
|
16
|
+
* open a 3D tour rather than to play something
|
|
17
|
+
* - Keyboard accessible facade with a required accessible `title`
|
|
18
|
+
*
|
|
19
|
+
* For Cloudflare Stream this embeds Cloudflare's own player. To play a
|
|
20
|
+
* Stream video with this design system's controls instead, use `Video`
|
|
21
|
+
* together with `cloudflareStream()`.
|
|
22
|
+
*
|
|
23
|
+
* Note: a derived YouTube poster is still fetched from `i.ytimg.com`, which
|
|
24
|
+
* is a static image host rather than the player. Pass your own `poster` to
|
|
25
|
+
* avoid contacting the provider entirely before playback.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```svelte
|
|
29
|
+
* <MediaEmbed url="https://youtu.be/dQw4w9WgXcQ" title="Never Gonna Give You Up" />
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @example Cloudflare Stream
|
|
33
|
+
* ```svelte
|
|
34
|
+
* <MediaEmbed
|
|
35
|
+
* provider="cloudflare"
|
|
36
|
+
* videoId="a1b2c3"
|
|
37
|
+
* customerCode="abc123"
|
|
38
|
+
* title="Product tour"
|
|
39
|
+
* />
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
import type {
|
|
43
|
+
MatterportPlayerOptions,
|
|
44
|
+
MediaAspectRatio,
|
|
45
|
+
MediaEmbedProvider,
|
|
46
|
+
} from '../../types/components.js';
|
|
47
|
+
import { cn } from '../../utils.js';
|
|
48
|
+
import {
|
|
49
|
+
buildEmbedSrc,
|
|
50
|
+
fetchMatterportMetadata,
|
|
51
|
+
getEmbedThumbnail,
|
|
52
|
+
parseEmbedUrl,
|
|
53
|
+
} from '../../utils/media.js';
|
|
54
|
+
import Icon from '../Icon.svelte';
|
|
55
|
+
import AspectRatio from './AspectRatio.svelte';
|
|
56
|
+
|
|
57
|
+
interface Props {
|
|
58
|
+
/** Full provider URL. Ignored when `provider` and `videoId` are given. */
|
|
59
|
+
url?: string;
|
|
60
|
+
/** Hosting provider */
|
|
61
|
+
provider?: MediaEmbedProvider;
|
|
62
|
+
/** Provider-specific video identifier */
|
|
63
|
+
videoId?: string;
|
|
64
|
+
/** Matterport space id. An alias for `videoId`, named for what it is. */
|
|
65
|
+
modelId?: string;
|
|
66
|
+
/** Showcase options, for `provider="matterport"` */
|
|
67
|
+
matterport?: MatterportPlayerOptions;
|
|
68
|
+
/**
|
|
69
|
+
* Look up the space's preview image and name from Matterport.
|
|
70
|
+
*
|
|
71
|
+
* Off by default: contacting the provider before the viewer clicks is
|
|
72
|
+
* what the lazy facade exists to avoid. Matterport publishes no derivable
|
|
73
|
+
* thumbnail, so without this, supply `poster` or accept a placeholder.
|
|
74
|
+
*/
|
|
75
|
+
fetchMetadata?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Accessible name for the embedded player. Required — an iframe with no
|
|
78
|
+
* title is announced only as "frame".
|
|
79
|
+
*/
|
|
80
|
+
title: string;
|
|
81
|
+
/**
|
|
82
|
+
* Poster image. Derived automatically for YouTube and Cloudflare Stream;
|
|
83
|
+
* required for Vimeo.
|
|
84
|
+
*/
|
|
85
|
+
poster?: string;
|
|
86
|
+
/** Route YouTube through the no-cookie domain (default `true`) */
|
|
87
|
+
privacy?: boolean;
|
|
88
|
+
/** Start offset in seconds */
|
|
89
|
+
start?: number;
|
|
90
|
+
/** Show the provider's related-video suggestions after playback */
|
|
91
|
+
showRelated?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Cloudflare Stream customer code, from the Stream dashboard. Required for
|
|
94
|
+
* `provider="cloudflare"` unless `domain` is given or a full `url` is used.
|
|
95
|
+
*/
|
|
96
|
+
customerCode?: string;
|
|
97
|
+
/** Custom hostname serving Cloudflare Stream */
|
|
98
|
+
domain?: string;
|
|
99
|
+
/** Accent colour for the Cloudflare player UI, any CSS colour */
|
|
100
|
+
primaryColor?: string;
|
|
101
|
+
/** Letterbox colour for the Cloudflare player, any CSS colour */
|
|
102
|
+
letterboxColor?: string;
|
|
103
|
+
/** BCP-47 language code of the captions track the Cloudflare player enables */
|
|
104
|
+
defaultTextTrack?: string;
|
|
105
|
+
/** Aspect ratio of the embed box */
|
|
106
|
+
ratio?: MediaAspectRatio | number;
|
|
107
|
+
/** Load the iframe immediately instead of showing a facade */
|
|
108
|
+
eager?: boolean;
|
|
109
|
+
/** Additional classes */
|
|
110
|
+
class?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let {
|
|
114
|
+
url,
|
|
115
|
+
provider,
|
|
116
|
+
videoId,
|
|
117
|
+
modelId,
|
|
118
|
+
matterport,
|
|
119
|
+
fetchMetadata = false,
|
|
120
|
+
title,
|
|
121
|
+
poster,
|
|
122
|
+
privacy = true,
|
|
123
|
+
start,
|
|
124
|
+
showRelated = false,
|
|
125
|
+
customerCode,
|
|
126
|
+
domain,
|
|
127
|
+
primaryColor,
|
|
128
|
+
letterboxColor,
|
|
129
|
+
defaultTextTrack,
|
|
130
|
+
ratio = 'video',
|
|
131
|
+
eager = false,
|
|
132
|
+
class: className,
|
|
133
|
+
}: Props = $props();
|
|
134
|
+
|
|
135
|
+
let activated = $state(false);
|
|
136
|
+
|
|
137
|
+
/** Preview image and name pulled from Matterport, when `fetchMetadata` is set. */
|
|
138
|
+
let fetchedPoster = $state<string | null>(null);
|
|
139
|
+
let fetchedName = $state<string | null>(null);
|
|
140
|
+
|
|
141
|
+
const parsed = $derived(url ? parseEmbedUrl(url) : null);
|
|
142
|
+
const resolvedProvider = $derived(provider ?? parsed?.provider ?? null);
|
|
143
|
+
const resolvedVideoId = $derived(videoId ?? modelId ?? parsed?.videoId ?? null);
|
|
144
|
+
const resolvedStart = $derived(start ?? parsed?.start);
|
|
145
|
+
const isMatterport = $derived(resolvedProvider === 'matterport');
|
|
146
|
+
const account = $derived({
|
|
147
|
+
customerCode: customerCode ?? parsed?.customerCode,
|
|
148
|
+
domain: domain ?? parsed?.domain,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const posterUrl = $derived(
|
|
152
|
+
poster ??
|
|
153
|
+
fetchedPoster ??
|
|
154
|
+
(resolvedProvider && resolvedVideoId
|
|
155
|
+
? getEmbedThumbnail(resolvedProvider, resolvedVideoId, account)
|
|
156
|
+
: null)
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const providerLabel = $derived(
|
|
160
|
+
resolvedProvider === 'vimeo'
|
|
161
|
+
? 'Vimeo'
|
|
162
|
+
: resolvedProvider === 'cloudflare'
|
|
163
|
+
? 'Cloudflare Stream'
|
|
164
|
+
: resolvedProvider === 'matterport'
|
|
165
|
+
? 'Matterport'
|
|
166
|
+
: 'YouTube'
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
/** Matterport's own name for the space, used when no `title` was supplied. */
|
|
170
|
+
const facadeTitle = $derived(title || fetchedName || '');
|
|
171
|
+
|
|
172
|
+
// Only reaches the network when the caller opted in, and never when a poster
|
|
173
|
+
// was supplied — there would be nothing to gain.
|
|
174
|
+
$effect(() => {
|
|
175
|
+
if (!isMatterport || !fetchMetadata || poster || !resolvedVideoId) return;
|
|
176
|
+
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
fetchMatterportMetadata(resolvedVideoId, { signal: controller.signal }).then((metadata) => {
|
|
179
|
+
if (controller.signal.aborted || !metadata) return;
|
|
180
|
+
fetchedPoster = metadata.image;
|
|
181
|
+
fetchedName = metadata.name || null;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return () => controller.abort();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const iframeSrc = $derived.by(() => {
|
|
188
|
+
if (!resolvedProvider || !resolvedVideoId) return null;
|
|
189
|
+
try {
|
|
190
|
+
return buildEmbedSrc(resolvedProvider, resolvedVideoId, {
|
|
191
|
+
privacy,
|
|
192
|
+
// Autoplay only once the viewer has already clicked play, which
|
|
193
|
+
// satisfies the browser's user-gesture requirement.
|
|
194
|
+
autoplay: activated,
|
|
195
|
+
start: resolvedStart,
|
|
196
|
+
showRelated,
|
|
197
|
+
primaryColor,
|
|
198
|
+
letterboxColor,
|
|
199
|
+
defaultTextTrack,
|
|
200
|
+
matterport,
|
|
201
|
+
...account,
|
|
202
|
+
});
|
|
203
|
+
} catch {
|
|
204
|
+
// A Stream embed with no resolvable account; the error state below
|
|
205
|
+
// explains it rather than crashing the render.
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const showIframe = $derived((eager || activated) && iframeSrc !== null);
|
|
211
|
+
const resolvable = $derived(
|
|
212
|
+
resolvedProvider !== null && resolvedVideoId !== null && iframeSrc !== null
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
216
|
+
$effect(() => {
|
|
217
|
+
if (!title) {
|
|
218
|
+
console.warn(
|
|
219
|
+
'MediaEmbed: Missing "title". An untitled iframe is announced only as "frame" by screen readers.'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (!resolvedProvider || !resolvedVideoId) {
|
|
223
|
+
console.warn(
|
|
224
|
+
`MediaEmbed: Could not resolve a provider and id${url ? ` from url "${url}"` : ''}. Pass a supported YouTube, Vimeo, Cloudflare Stream, or Matterport URL, or set "provider" with "videoId" (or "modelId" for Matterport) directly.`
|
|
225
|
+
);
|
|
226
|
+
} else if (resolvedProvider === 'cloudflare' && !iframeSrc) {
|
|
227
|
+
console.warn(
|
|
228
|
+
'MediaEmbed: Cloudflare Stream needs an account. Pass "customerCode" from the Stream dashboard, or "domain" when serving Stream from a custom hostname.'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (resolvedProvider === 'vimeo' && !poster && !eager) {
|
|
232
|
+
console.warn(
|
|
233
|
+
'MediaEmbed: Vimeo thumbnails cannot be derived without a network request. Pass "poster" so the facade has an image, or set "eager".'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
</script>
|
|
239
|
+
|
|
240
|
+
<AspectRatio {ratio} class={cn('relative bg-muted', className)}>
|
|
241
|
+
{#if !resolvable}
|
|
242
|
+
<div
|
|
243
|
+
class="flex h-full w-full flex-col items-center justify-center gap-2 p-4 text-center text-muted-foreground"
|
|
244
|
+
role="alert"
|
|
245
|
+
>
|
|
246
|
+
<Icon name={isMatterport ? 'box' : 'video'} size={28} />
|
|
247
|
+
<p class="text-sm">
|
|
248
|
+
{isMatterport ? 'This 3D tour could not be loaded.' : 'This video could not be loaded.'}
|
|
249
|
+
</p>
|
|
250
|
+
</div>
|
|
251
|
+
{:else if showIframe}
|
|
252
|
+
<iframe
|
|
253
|
+
src={iframeSrc}
|
|
254
|
+
{title}
|
|
255
|
+
class="h-full w-full border-0"
|
|
256
|
+
loading="lazy"
|
|
257
|
+
referrerpolicy="strict-origin-when-cross-origin"
|
|
258
|
+
allow={isMatterport
|
|
259
|
+
? 'xr-spatial-tracking; fullscreen; accelerometer; gyroscope; vr'
|
|
260
|
+
: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'}
|
|
261
|
+
allowfullscreen
|
|
262
|
+
></iframe>
|
|
263
|
+
{:else}
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
class="group/facade relative h-full w-full overflow-hidden bg-black outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
267
|
+
onclick={() => (activated = true)}
|
|
268
|
+
aria-label={isMatterport
|
|
269
|
+
? `Open ${facadeTitle} as a 3D tour on ${providerLabel}`
|
|
270
|
+
: `Play ${facadeTitle} on ${providerLabel}`}
|
|
271
|
+
>
|
|
272
|
+
{#if posterUrl}
|
|
273
|
+
<img
|
|
274
|
+
src={posterUrl}
|
|
275
|
+
alt=""
|
|
276
|
+
aria-hidden="true"
|
|
277
|
+
loading="lazy"
|
|
278
|
+
decoding="async"
|
|
279
|
+
class="h-full w-full object-cover opacity-90"
|
|
280
|
+
/>
|
|
281
|
+
{:else if isMatterport}
|
|
282
|
+
<!--
|
|
283
|
+
Matterport publishes no derivable thumbnail, so an unposted tour gets
|
|
284
|
+
a deliberate placeholder rather than a bare black rectangle.
|
|
285
|
+
-->
|
|
286
|
+
<span
|
|
287
|
+
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-neutral-800 to-neutral-900"
|
|
288
|
+
aria-hidden="true"
|
|
289
|
+
></span>
|
|
290
|
+
{/if}
|
|
291
|
+
|
|
292
|
+
<span
|
|
293
|
+
class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/20"
|
|
294
|
+
aria-hidden="true"
|
|
295
|
+
>
|
|
296
|
+
<span
|
|
297
|
+
class="flex h-16 w-16 items-center justify-center rounded-full bg-black/70 text-white shadow-lg"
|
|
298
|
+
>
|
|
299
|
+
<Icon name={isMatterport ? 'box' : 'play'} size={32} />
|
|
300
|
+
</span>
|
|
301
|
+
{#if isMatterport}
|
|
302
|
+
<span class="text-sm font-medium text-white drop-shadow">3D tour</span>
|
|
303
|
+
{/if}
|
|
304
|
+
</span>
|
|
305
|
+
|
|
306
|
+
<span
|
|
307
|
+
class="absolute bottom-2 right-2 rounded bg-black/70 px-2 py-0.5 text-xs font-medium text-white"
|
|
308
|
+
aria-hidden="true"
|
|
309
|
+
>
|
|
310
|
+
{providerLabel}
|
|
311
|
+
</span>
|
|
312
|
+
</button>
|
|
313
|
+
{/if}
|
|
314
|
+
</AspectRatio>
|