@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.
Files changed (51) hide show
  1. package/README.md +43 -0
  2. package/dist/__tests__/a11y/Media.a11y.test.js +145 -0
  3. package/dist/__tests__/media-components.test.js +206 -0
  4. package/dist/__tests__/media-hls.test.js +273 -0
  5. package/dist/__tests__/media-matterport.test.js +296 -0
  6. package/dist/__tests__/media-players.test.js +244 -0
  7. package/dist/__tests__/media-scrubber.test.js +44 -0
  8. package/dist/__tests__/media-utils.test.js +427 -0
  9. package/dist/__tests__/setup.js +50 -0
  10. package/dist/lib/components/FileUpload.svelte +221 -4
  11. package/dist/lib/components/FileUpload.svelte.d.ts +13 -1
  12. package/dist/lib/components/media/AspectRatio.svelte +47 -0
  13. package/dist/lib/components/media/AspectRatio.svelte.d.ts +20 -0
  14. package/dist/lib/components/media/Audio.svelte +247 -0
  15. package/dist/lib/components/media/Audio.svelte.d.ts +34 -0
  16. package/dist/lib/components/media/Image.svelte +209 -0
  17. package/dist/lib/components/media/Image.svelte.d.ts +52 -0
  18. package/dist/lib/components/media/Lightbox.svelte +449 -0
  19. package/dist/lib/components/media/Lightbox.svelte.d.ts +33 -0
  20. package/dist/lib/components/media/MediaCarousel.svelte +359 -0
  21. package/dist/lib/components/media/MediaCarousel.svelte.d.ts +30 -0
  22. package/dist/lib/components/media/MediaEmbed.svelte +314 -0
  23. package/dist/lib/components/media/MediaEmbed.svelte.d.ts +99 -0
  24. package/dist/lib/components/media/MediaGrid.svelte +327 -0
  25. package/dist/lib/components/media/MediaGrid.svelte.d.ts +57 -0
  26. package/dist/lib/components/media/Video.svelte +455 -0
  27. package/dist/lib/components/media/Video.svelte.d.ts +81 -0
  28. package/dist/lib/components/media/index.d.ts +14 -0
  29. package/dist/lib/components/media/index.js +14 -0
  30. package/dist/lib/components/media/internal/MediaControls.svelte +283 -0
  31. package/dist/lib/components/media/internal/MediaControls.svelte.d.ts +32 -0
  32. package/dist/lib/components/media/internal/MediaScrubber.svelte +211 -0
  33. package/dist/lib/components/media/internal/MediaScrubber.svelte.d.ts +27 -0
  34. package/dist/lib/components/media/internal/MediaTile.svelte +129 -0
  35. package/dist/lib/components/media/internal/MediaTile.svelte.d.ts +22 -0
  36. package/dist/lib/composables/index.d.ts +3 -0
  37. package/dist/lib/composables/index.js +4 -0
  38. package/dist/lib/composables/useHlsSource.svelte.d.ts +70 -0
  39. package/dist/lib/composables/useHlsSource.svelte.js +119 -0
  40. package/dist/lib/composables/useMediaPlayer.svelte.d.ts +114 -0
  41. package/dist/lib/composables/useMediaPlayer.svelte.js +422 -0
  42. package/dist/lib/composables/useMediaZoom.svelte.d.ts +59 -0
  43. package/dist/lib/composables/useMediaZoom.svelte.js +199 -0
  44. package/dist/lib/index.d.ts +8 -2
  45. package/dist/lib/index.js +14 -1
  46. package/dist/lib/types/components.d.ts +172 -0
  47. package/dist/lib/utils/hls.d.ts +76 -0
  48. package/dist/lib/utils/hls.js +83 -0
  49. package/dist/lib/utils/media.d.ts +379 -0
  50. package/dist/lib/utils/media.js +715 -0
  51. 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>