@classic-homes/theme-svelte 1.2.3 → 1.4.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 (52) 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/DataPanel.svelte +20 -19
  11. package/dist/lib/components/FileUpload.svelte +221 -4
  12. package/dist/lib/components/FileUpload.svelte.d.ts +13 -1
  13. package/dist/lib/components/media/AspectRatio.svelte +47 -0
  14. package/dist/lib/components/media/AspectRatio.svelte.d.ts +20 -0
  15. package/dist/lib/components/media/Audio.svelte +247 -0
  16. package/dist/lib/components/media/Audio.svelte.d.ts +34 -0
  17. package/dist/lib/components/media/Image.svelte +209 -0
  18. package/dist/lib/components/media/Image.svelte.d.ts +52 -0
  19. package/dist/lib/components/media/Lightbox.svelte +449 -0
  20. package/dist/lib/components/media/Lightbox.svelte.d.ts +33 -0
  21. package/dist/lib/components/media/MediaCarousel.svelte +359 -0
  22. package/dist/lib/components/media/MediaCarousel.svelte.d.ts +30 -0
  23. package/dist/lib/components/media/MediaEmbed.svelte +314 -0
  24. package/dist/lib/components/media/MediaEmbed.svelte.d.ts +99 -0
  25. package/dist/lib/components/media/MediaGrid.svelte +327 -0
  26. package/dist/lib/components/media/MediaGrid.svelte.d.ts +57 -0
  27. package/dist/lib/components/media/Video.svelte +455 -0
  28. package/dist/lib/components/media/Video.svelte.d.ts +81 -0
  29. package/dist/lib/components/media/index.d.ts +14 -0
  30. package/dist/lib/components/media/index.js +14 -0
  31. package/dist/lib/components/media/internal/MediaControls.svelte +283 -0
  32. package/dist/lib/components/media/internal/MediaControls.svelte.d.ts +32 -0
  33. package/dist/lib/components/media/internal/MediaScrubber.svelte +211 -0
  34. package/dist/lib/components/media/internal/MediaScrubber.svelte.d.ts +27 -0
  35. package/dist/lib/components/media/internal/MediaTile.svelte +129 -0
  36. package/dist/lib/components/media/internal/MediaTile.svelte.d.ts +22 -0
  37. package/dist/lib/composables/index.d.ts +3 -0
  38. package/dist/lib/composables/index.js +4 -0
  39. package/dist/lib/composables/useHlsSource.svelte.d.ts +70 -0
  40. package/dist/lib/composables/useHlsSource.svelte.js +119 -0
  41. package/dist/lib/composables/useMediaPlayer.svelte.d.ts +114 -0
  42. package/dist/lib/composables/useMediaPlayer.svelte.js +422 -0
  43. package/dist/lib/composables/useMediaZoom.svelte.d.ts +59 -0
  44. package/dist/lib/composables/useMediaZoom.svelte.js +199 -0
  45. package/dist/lib/index.d.ts +8 -2
  46. package/dist/lib/index.js +14 -1
  47. package/dist/lib/types/components.d.ts +172 -0
  48. package/dist/lib/utils/hls.d.ts +76 -0
  49. package/dist/lib/utils/hls.js +83 -0
  50. package/dist/lib/utils/media.d.ts +379 -0
  51. package/dist/lib/utils/media.js +715 -0
  52. package/package.json +6 -2
@@ -0,0 +1,715 @@
1
+ /**
2
+ * Media utilities
3
+ *
4
+ * Pure helpers shared by the media components. Nothing here touches the DOM or
5
+ * component state, so it is all directly unit-testable.
6
+ */
7
+ /** Numeric width/height ratios for the named aspect ratios. */
8
+ export const ASPECT_RATIOS = {
9
+ square: 1,
10
+ video: 16 / 9,
11
+ photo: 4 / 3,
12
+ portrait: 3 / 4,
13
+ wide: 21 / 9,
14
+ };
15
+ /**
16
+ * Resolve a ratio prop to a numeric width/height ratio.
17
+ *
18
+ * @example
19
+ * resolveAspectRatio('video') // 1.777…
20
+ * resolveAspectRatio(2.35) // 2.35
21
+ */
22
+ export function resolveAspectRatio(ratio) {
23
+ return typeof ratio === 'number' ? ratio : ASPECT_RATIOS[ratio];
24
+ }
25
+ /**
26
+ * Format a duration in seconds as `m:ss`, or `h:mm:ss` past an hour.
27
+ *
28
+ * Non-finite and negative inputs collapse to `0:00` so a player showing
29
+ * `NaN` before metadata loads is impossible.
30
+ *
31
+ * @example
32
+ * formatDuration(83) // '1:23'
33
+ * formatDuration(3723) // '1:02:03'
34
+ * formatDuration(NaN) // '0:00'
35
+ */
36
+ export function formatDuration(seconds) {
37
+ if (!Number.isFinite(seconds) || seconds < 0)
38
+ return '0:00';
39
+ const total = Math.floor(seconds);
40
+ const hours = Math.floor(total / 3600);
41
+ const minutes = Math.floor((total % 3600) / 60);
42
+ const secs = total % 60;
43
+ const paddedSecs = String(secs).padStart(2, '0');
44
+ if (hours > 0) {
45
+ return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSecs}`;
46
+ }
47
+ return `${minutes}:${paddedSecs}`;
48
+ }
49
+ /**
50
+ * Spoken form of a duration, for `aria-label` and `aria-valuetext`.
51
+ *
52
+ * Screen readers announce `1:23` as "one twenty-three", which is ambiguous.
53
+ *
54
+ * @example
55
+ * formatDurationSpoken(83) // '1 minute 23 seconds'
56
+ */
57
+ export function formatDurationSpoken(seconds) {
58
+ if (!Number.isFinite(seconds) || seconds < 0)
59
+ return '0 seconds';
60
+ const total = Math.floor(seconds);
61
+ const hours = Math.floor(total / 3600);
62
+ const minutes = Math.floor((total % 3600) / 60);
63
+ const secs = total % 60;
64
+ const parts = [];
65
+ if (hours > 0)
66
+ parts.push(`${hours} hour${hours === 1 ? '' : 's'}`);
67
+ if (minutes > 0)
68
+ parts.push(`${minutes} minute${minutes === 1 ? '' : 's'}`);
69
+ if (secs > 0 || parts.length === 0)
70
+ parts.push(`${secs} second${secs === 1 ? '' : 's'}`);
71
+ return parts.join(' ');
72
+ }
73
+ const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico'];
74
+ const VIDEO_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'm4v', 'mkv'];
75
+ const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus'];
76
+ /**
77
+ * Extract the lower-cased file extension from a URL, ignoring query strings
78
+ * and hash fragments.
79
+ */
80
+ export function getFileExtension(url) {
81
+ const withoutQuery = url.split(/[?#]/)[0];
82
+ const lastSegment = withoutQuery.split('/').pop() ?? '';
83
+ const dotIndex = lastSegment.lastIndexOf('.');
84
+ if (dotIndex === -1 || dotIndex === lastSegment.length - 1)
85
+ return '';
86
+ return lastSegment.slice(dotIndex + 1).toLowerCase();
87
+ }
88
+ /**
89
+ * Infer a media kind from a URL or MIME type.
90
+ *
91
+ * MIME type wins when supplied, since a URL may have no extension at all.
92
+ * Returns `null` when nothing can be inferred, so callers can decide whether
93
+ * to fall back or warn.
94
+ *
95
+ * @example
96
+ * inferMediaKind('/clip.mp4') // 'video'
97
+ * inferMediaKind('/asset', 'image/png') // 'image'
98
+ * inferMediaKind('https://youtu.be/dQw4w9Wg') // 'embed'
99
+ */
100
+ export function inferMediaKind(url, mimeType) {
101
+ if (mimeType) {
102
+ if (mimeType.startsWith('image/'))
103
+ return 'image';
104
+ if (mimeType.startsWith('video/'))
105
+ return 'video';
106
+ if (mimeType.startsWith('audio/'))
107
+ return 'audio';
108
+ }
109
+ if (parseEmbedUrl(url))
110
+ return 'embed';
111
+ const extension = getFileExtension(url);
112
+ if (IMAGE_EXTENSIONS.includes(extension))
113
+ return 'image';
114
+ if (VIDEO_EXTENSIONS.includes(extension))
115
+ return 'video';
116
+ if (AUDIO_EXTENSIONS.includes(extension))
117
+ return 'audio';
118
+ return null;
119
+ }
120
+ /** MIME type browsers use for HLS playlists. */
121
+ export const HLS_MIME_TYPE = 'application/vnd.apple.mpegurl';
122
+ /** MIME type browsers use for MPEG-DASH manifests. */
123
+ export const DASH_MIME_TYPE = 'application/dash+xml';
124
+ /**
125
+ * Whether a source is an HLS playlist.
126
+ *
127
+ * Checks the declared MIME type first, then the `.m3u8` extension, so a
128
+ * manifest served from an extensionless or query-laden URL still resolves
129
+ * correctly as long as its type is declared.
130
+ */
131
+ export function isHlsSource(source) {
132
+ const src = typeof source === 'string' ? source : source.src;
133
+ const type = typeof source === 'string' ? undefined : source.type;
134
+ if (type)
135
+ return type.toLowerCase() === HLS_MIME_TYPE || type.toLowerCase() === 'application/x-mpegurl';
136
+ const extension = getFileExtension(src);
137
+ return extension === 'm3u8';
138
+ }
139
+ /** Normalize a `src` prop to the list of sources a `<video>`/`<audio>` should render. */
140
+ export function toMediaSources(src) {
141
+ if (Array.isArray(src))
142
+ return src;
143
+ const extension = getFileExtension(src);
144
+ const type = extension ? guessMimeType(extension) : undefined;
145
+ return [{ src, type }];
146
+ }
147
+ /** Best-effort MIME type for a media file extension. */
148
+ export function guessMimeType(extension) {
149
+ const map = {
150
+ m3u8: HLS_MIME_TYPE,
151
+ mpd: DASH_MIME_TYPE,
152
+ mp4: 'video/mp4',
153
+ m4v: 'video/mp4',
154
+ webm: 'video/webm',
155
+ ogv: 'video/ogg',
156
+ mov: 'video/quicktime',
157
+ mp3: 'audio/mpeg',
158
+ wav: 'audio/wav',
159
+ ogg: 'audio/ogg',
160
+ oga: 'audio/ogg',
161
+ m4a: 'audio/mp4',
162
+ aac: 'audio/aac',
163
+ flac: 'audio/flac',
164
+ opus: 'audio/opus',
165
+ };
166
+ return map[extension.toLowerCase()];
167
+ }
168
+ /**
169
+ * Cloudflare Stream
170
+ *
171
+ * Stream serves every asset for a video from one host, keyed by the video UID.
172
+ * A signed playback token substitutes for the UID in exactly the same
173
+ * position, so private videos need no separate code path — pass the token as
174
+ * `videoId`.
175
+ *
176
+ * @see https://developers.cloudflare.com/stream/viewing-videos/using-own-player/
177
+ */
178
+ /** Resolve the host serving a Stream account's assets. */
179
+ export function cloudflareStreamHost(account) {
180
+ if (account.domain)
181
+ return account.domain.replace(/^https?:\/\//, '').replace(/\/$/, '');
182
+ if (account.customerCode)
183
+ return `customer-${account.customerCode}.cloudflarestream.com`;
184
+ return null;
185
+ }
186
+ /**
187
+ * Thumbnail URL for a Cloudflare Stream video.
188
+ *
189
+ * Unlike Vimeo, Stream generates thumbnails on demand from a predictable URL,
190
+ * so a poster never needs a network lookup to discover.
191
+ *
192
+ * @example
193
+ * cloudflareStreamThumbnail('a1b2c3', { customerCode: 'abc', time: '1s', height: 600 })
194
+ * // 'https://customer-abc.cloudflarestream.com/a1b2c3/thumbnails/thumbnail.jpg?time=1s&height=600'
195
+ */
196
+ export function cloudflareStreamThumbnail(videoId, options) {
197
+ const host = cloudflareStreamHost(options);
198
+ if (!host || !videoId)
199
+ return null;
200
+ const params = new URLSearchParams();
201
+ if (options.time)
202
+ params.set('time', options.time);
203
+ if (options.height !== undefined)
204
+ params.set('height', String(options.height));
205
+ if (options.width !== undefined)
206
+ params.set('width', String(options.width));
207
+ if (options.fit)
208
+ params.set('fit', options.fit);
209
+ if (options.animated) {
210
+ if (options.duration)
211
+ params.set('duration', options.duration);
212
+ if (options.fps !== undefined)
213
+ params.set('fps', String(options.fps));
214
+ }
215
+ const file = options.animated ? 'thumbnail.gif' : 'thumbnail.jpg';
216
+ const query = params.toString();
217
+ return `https://${host}/${videoId}/thumbnails/${file}${query ? `?${query}` : ''}`;
218
+ }
219
+ /**
220
+ * Build the playback URLs for a Cloudflare Stream video.
221
+ *
222
+ * Returns `null` when the account cannot be resolved, so a missing customer
223
+ * code surfaces as a clear failure rather than a malformed request.
224
+ *
225
+ * @example
226
+ * cloudflareStreamUrls('a1b2c3', { customerCode: 'abc' })?.hls
227
+ * // 'https://customer-abc.cloudflarestream.com/a1b2c3/manifest/video.m3u8'
228
+ */
229
+ export function cloudflareStreamUrls(videoId, options) {
230
+ const host = cloudflareStreamHost(options);
231
+ if (!host || !videoId)
232
+ return null;
233
+ const params = new URLSearchParams();
234
+ if (options.lowLatency)
235
+ params.set('protocol', 'llhls');
236
+ if (options.clientBandwidthHint !== undefined) {
237
+ params.set('clientBandwidthHint', String(options.clientBandwidthHint));
238
+ }
239
+ const query = params.toString();
240
+ const base = `https://${host}/${videoId}`;
241
+ return {
242
+ hls: `${base}/manifest/video.m3u8${query ? `?${query}` : ''}`,
243
+ dash: `${base}/manifest/video.mpd${query ? `?${query}` : ''}`,
244
+ iframe: `${base}/iframe`,
245
+ thumbnail: `${base}/thumbnails/thumbnail.jpg`,
246
+ download: `${base}/downloads/default.mp4`,
247
+ };
248
+ }
249
+ /**
250
+ * Build a media item for a Cloudflare Stream video, playable by `Video`,
251
+ * `MediaGrid`, `MediaCarousel`, and `Lightbox`.
252
+ *
253
+ * The item's source is the HLS playlist, so playback uses the design system's
254
+ * own controls rather than Cloudflare's player. Use `MediaEmbed` with
255
+ * `provider="cloudflare"` when you want Cloudflare's player instead.
256
+ *
257
+ * Throws when the account cannot be resolved, because every URL on the
258
+ * returned item would otherwise be malformed.
259
+ *
260
+ * @example
261
+ * ```svelte
262
+ * <script lang="ts">
263
+ * import { Video, cloudflareStream } from '@classic-homes/theme-svelte';
264
+ *
265
+ * const tour = cloudflareStream('a1b2c3', {
266
+ * customerCode: 'abc123',
267
+ * title: 'Product tour',
268
+ * });
269
+ * </script>
270
+ *
271
+ * <Video item={tour} />
272
+ * ```
273
+ */
274
+ export function cloudflareStream(videoId, options) {
275
+ const urls = cloudflareStreamUrls(videoId, options);
276
+ if (!urls) {
277
+ throw new Error('cloudflareStream: could not resolve the Stream host. Pass "customerCode" (from the Stream dashboard) or "domain".');
278
+ }
279
+ const poster = options.poster ??
280
+ cloudflareStreamThumbnail(videoId, {
281
+ customerCode: options.customerCode,
282
+ domain: options.domain,
283
+ time: options.posterTime ?? '1s',
284
+ height: options.posterHeight ?? 720,
285
+ }) ??
286
+ undefined;
287
+ return {
288
+ kind: 'video',
289
+ id: options.id ?? videoId,
290
+ src: [{ src: urls.hls, type: HLS_MIME_TYPE }],
291
+ poster,
292
+ thumbnail: poster,
293
+ title: options.title,
294
+ caption: options.caption,
295
+ duration: options.duration,
296
+ tracks: options.tracks,
297
+ downloadUrl: options.downloadable ? urls.download : undefined,
298
+ };
299
+ }
300
+ /**
301
+ * Matterport
302
+ *
303
+ * Matterport embeds an interactive 3D space, not a video, so it has no
304
+ * timeline, no duration, and no derivable thumbnail. The showcase player is
305
+ * driven entirely by query parameters on one URL.
306
+ *
307
+ * @see https://support.matterport.com/s/article/URL-Parameters
308
+ */
309
+ /** Hosts that serve the Matterport showcase player. */
310
+ const MATTERPORT_HOSTS = ['my.matterport.com', 'matterport.com'];
311
+ /**
312
+ * Build the showcase URL for a Matterport space.
313
+ *
314
+ * Matterport's own defaults are left alone unless an option asks otherwise,
315
+ * so the embed looks the way the space's owner configured it.
316
+ *
317
+ * @example
318
+ * buildMatterportSrc('SxQL3iGyoDo', { brand: false })
319
+ * // 'https://my.matterport.com/show/?m=SxQL3iGyoDo&qs=1&brand=0'
320
+ */
321
+ export function buildMatterportSrc(modelId, options = {}) {
322
+ const params = new URLSearchParams({ m: modelId });
323
+ // Quick start is on by default: without it the space shows a black frame
324
+ // for several seconds before the first view resolves.
325
+ if (options.quickStart !== false)
326
+ params.set('qs', '1');
327
+ if (options.autoplay)
328
+ params.set('play', '1');
329
+ if (options.brand === false)
330
+ params.set('brand', '0');
331
+ if (options.help === false)
332
+ params.set('help', '0');
333
+ if (options.highlightReel === false)
334
+ params.set('hr', '0');
335
+ if (options.vr === false)
336
+ params.set('vr', '0');
337
+ if (options.floorSelector === false)
338
+ params.set('f', '0');
339
+ if (options.mls)
340
+ params.set('mls', '1');
341
+ if (options.lang)
342
+ params.set('lang', options.lang);
343
+ if (options.guidedTourStart !== undefined) {
344
+ params.set('ts', String(Math.max(0, Math.floor(options.guidedTourStart))));
345
+ }
346
+ // Escape hatch for the long tail of showcase parameters.
347
+ for (const [key, value] of Object.entries(options.params ?? {})) {
348
+ params.set(key, typeof value === 'boolean' ? (value ? '1' : '0') : String(value));
349
+ }
350
+ return `https://my.matterport.com/show/?${params.toString()}`;
351
+ }
352
+ /**
353
+ * Choose a usable preview image from a space's metadata.
354
+ *
355
+ * The payload's top-level `image` is deliberately ignored: Matterport hands it
356
+ * out already expired, and requesting it answers 410. The signatures inside
357
+ * `images` are freshly minted, so those are the only ones worth using — the
358
+ * hero shot first, and its thumbnail variant ahead of the full-size original,
359
+ * which is tens of times larger for no benefit at facade size.
360
+ */
361
+ function pickMatterportImage(images) {
362
+ const ordered = [...images].sort((a, b) => Number(Boolean(b.is_hero)) - Number(Boolean(a.is_hero)));
363
+ for (const image of ordered) {
364
+ const url = image.thumbnail_signed_src ?? image.signed_src;
365
+ if (url)
366
+ return url;
367
+ }
368
+ return null;
369
+ }
370
+ /**
371
+ * Look up a Matterport space's name and preview image.
372
+ *
373
+ * Matterport publishes no oEmbed endpoint and no derivable thumbnail URL, so
374
+ * a real preview needs this request. It is never made automatically — the
375
+ * caller opts in — because contacting the provider before the viewer clicks is
376
+ * exactly what the lazy facade exists to avoid.
377
+ *
378
+ * The returned image URL is signed and short-lived, so it must be used
379
+ * promptly rather than stored.
380
+ *
381
+ * Resolves to `null` on any failure rather than throwing, so a preview is
382
+ * always an enhancement and never breaks a render.
383
+ */
384
+ export async function fetchMatterportMetadata(modelId, options = {}) {
385
+ if (!modelId)
386
+ return null;
387
+ try {
388
+ const response = await fetch(`https://my.matterport.com/api/v1/player/models/${encodeURIComponent(modelId)}/`, { signal: options.signal });
389
+ if (!response.ok)
390
+ return null;
391
+ const data = (await response.json());
392
+ return { name: data.name ?? '', image: pickMatterportImage(data.images ?? []) };
393
+ }
394
+ catch {
395
+ return null;
396
+ }
397
+ }
398
+ /**
399
+ * Build a media item for a Matterport space, usable by `MediaEmbed`,
400
+ * `MediaGrid`, `MediaCarousel`, and `Lightbox`.
401
+ *
402
+ * @example
403
+ * ```svelte
404
+ * <script lang="ts">
405
+ * import { MediaGrid, matterportTour } from '@classic-homes/theme-svelte';
406
+ *
407
+ * const items = [matterportTour('SxQL3iGyoDo', { title: 'Show home' })];
408
+ * </script>
409
+ *
410
+ * <MediaGrid {items} />
411
+ * ```
412
+ */
413
+ export function matterportTour(modelId, options = {}) {
414
+ return {
415
+ kind: 'embed',
416
+ provider: 'matterport',
417
+ id: options.id ?? modelId,
418
+ videoId: modelId,
419
+ title: options.title,
420
+ caption: options.caption,
421
+ poster: options.poster,
422
+ thumbnail: options.poster,
423
+ matterport: {
424
+ autoplay: options.autoplay,
425
+ quickStart: options.quickStart,
426
+ brand: options.brand,
427
+ help: options.help,
428
+ highlightReel: options.highlightReel,
429
+ vr: options.vr,
430
+ floorSelector: options.floorSelector,
431
+ guidedTourStart: options.guidedTourStart,
432
+ mls: options.mls,
433
+ lang: options.lang,
434
+ params: options.params,
435
+ },
436
+ };
437
+ }
438
+ const YOUTUBE_ID = /^[\w-]{11}$/;
439
+ const VIMEO_ID = /^\d+$/;
440
+ /**
441
+ * Parse a YouTube, Vimeo, or Cloudflare Stream URL into a provider and video id.
442
+ *
443
+ * Handles `youtube.com/watch?v=`, `youtu.be/`, `/embed/`, `/shorts/`, and
444
+ * `/live/` for YouTube; `vimeo.com/<id>` (including unlisted-hash and
445
+ * `player.vimeo.com/video/<id>` forms) for Vimeo; and every
446
+ * `customer-<CODE>.cloudflarestream.com/<UID>/…` form plus the legacy
447
+ * `videodelivery.net` hosts for Stream. Returns `null` for anything else
448
+ * rather than guessing.
449
+ *
450
+ * @example
451
+ * parseEmbedUrl('https://youtu.be/dQw4w9WgXcQ?t=30')
452
+ * // { provider: 'youtube', videoId: 'dQw4w9WgXcQ', start: 30 }
453
+ *
454
+ * @example
455
+ * parseEmbedUrl('https://customer-abc.cloudflarestream.com/a1b2c3/iframe')
456
+ * // { provider: 'cloudflare', videoId: 'a1b2c3', customerCode: 'abc' }
457
+ */
458
+ export function parseEmbedUrl(url) {
459
+ if (!url)
460
+ return null;
461
+ let parsed;
462
+ try {
463
+ parsed = new URL(url, 'https://placeholder.invalid');
464
+ }
465
+ catch {
466
+ return null;
467
+ }
468
+ const host = parsed.hostname.replace(/^www\./, '');
469
+ const segments = parsed.pathname.split('/').filter(Boolean);
470
+ const start = parseStartParam(parsed.searchParams.get('t') ?? parsed.searchParams.get('start'));
471
+ const cloudflare = parseCloudflareStreamHost(host, segments, parsed);
472
+ if (cloudflare)
473
+ return withStart(cloudflare, start);
474
+ if (MATTERPORT_HOSTS.includes(host)) {
475
+ // The showcase always carries the space in `?m=`; `/show/` is just the
476
+ // player route. Anything else on the domain is a marketing page.
477
+ const modelId = parsed.searchParams.get('m');
478
+ return modelId ? { provider: 'matterport', videoId: modelId } : null;
479
+ }
480
+ if (host === 'youtu.be') {
481
+ const videoId = segments[0];
482
+ return videoId && YOUTUBE_ID.test(videoId)
483
+ ? withStart({ provider: 'youtube', videoId }, start)
484
+ : null;
485
+ }
486
+ if (host === 'youtube.com' || host === 'youtube-nocookie.com' || host === 'm.youtube.com') {
487
+ const fromQuery = parsed.searchParams.get('v');
488
+ if (fromQuery && YOUTUBE_ID.test(fromQuery)) {
489
+ return withStart({ provider: 'youtube', videoId: fromQuery }, start);
490
+ }
491
+ // /embed/<id>, /shorts/<id>, /live/<id>, /v/<id>
492
+ if (['embed', 'shorts', 'live', 'v'].includes(segments[0])) {
493
+ const videoId = segments[1];
494
+ return videoId && YOUTUBE_ID.test(videoId)
495
+ ? withStart({ provider: 'youtube', videoId }, start)
496
+ : null;
497
+ }
498
+ return null;
499
+ }
500
+ if (host === 'vimeo.com' || host === 'player.vimeo.com') {
501
+ // player.vimeo.com/video/<id>, vimeo.com/<id>, vimeo.com/channels/x/<id>
502
+ const videoId = [...segments].reverse().find((segment) => VIMEO_ID.test(segment));
503
+ return videoId ? withStart({ provider: 'vimeo', videoId }, start) : null;
504
+ }
505
+ return null;
506
+ }
507
+ function withStart(embed, start) {
508
+ return start === undefined ? embed : { ...embed, start };
509
+ }
510
+ /**
511
+ * Recognise a Cloudflare Stream URL.
512
+ *
513
+ * Stream puts the video UID — or a signed playback token, which can be
514
+ * hundreds of characters — directly after the host, followed by the asset
515
+ * being requested (`iframe`, `watch`, `manifest/…`, `thumbnails/…`). The id is
516
+ * therefore whatever the first path segment is, with no shape to validate
517
+ * against.
518
+ */
519
+ function parseCloudflareStreamHost(host, segments, parsed) {
520
+ const customerMatch = host.match(/^customer-([^.]+)\.cloudflarestream\.com$/);
521
+ const isLegacy = host === 'videodelivery.net' || host === 'iframe.videodelivery.net';
522
+ if (!customerMatch && !isLegacy)
523
+ return null;
524
+ const videoId = segments[0];
525
+ if (!videoId)
526
+ return null;
527
+ // Stream's own player uses `startTime` rather than YouTube's `t`.
528
+ const startTime = parseStartParam(parsed.searchParams.get('startTime'));
529
+ const embed = { provider: 'cloudflare', videoId };
530
+ if (customerMatch)
531
+ embed.customerCode = customerMatch[1];
532
+ if (startTime !== undefined)
533
+ embed.start = startTime;
534
+ return embed;
535
+ }
536
+ /** Parse YouTube's `t` parameter, which accepts both `90` and `1m30s`. */
537
+ function parseStartParam(raw) {
538
+ if (!raw)
539
+ return undefined;
540
+ if (/^\d+$/.test(raw))
541
+ return Number(raw);
542
+ const match = raw.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
543
+ if (!match || !match.slice(1).some(Boolean))
544
+ return undefined;
545
+ const [, hours, minutes, seconds] = match;
546
+ return Number(hours ?? 0) * 3600 + Number(minutes ?? 0) * 60 + Number(seconds ?? 0);
547
+ }
548
+ /**
549
+ * Build the iframe `src` for an embedded video.
550
+ *
551
+ * YouTube defaults to the `youtube-nocookie.com` domain, which does not set
552
+ * tracking cookies until playback begins. Cloudflare Stream needs the account
553
+ * to be resolvable, so pass `customerCode` or `domain` for that provider.
554
+ *
555
+ * @example
556
+ * buildEmbedSrc('youtube', 'dQw4w9WgXcQ', { autoplay: true, start: 30 })
557
+ * // 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&start=30&rel=0'
558
+ *
559
+ * @example
560
+ * buildEmbedSrc('cloudflare', 'a1b2c3', { customerCode: 'abc' })
561
+ * // 'https://customer-abc.cloudflarestream.com/a1b2c3/iframe'
562
+ */
563
+ export function buildEmbedSrc(provider, videoId, options = {}) {
564
+ const { privacy = true, autoplay = false, start, showRelated = false } = options;
565
+ const params = new URLSearchParams();
566
+ if (provider === 'matterport') {
567
+ return buildMatterportSrc(videoId, { ...options.matterport, autoplay });
568
+ }
569
+ if (provider === 'cloudflare') {
570
+ const host = cloudflareStreamHost(options);
571
+ if (!host) {
572
+ throw new Error('buildEmbedSrc: Cloudflare Stream needs "customerCode" (from the Stream dashboard) or "domain".');
573
+ }
574
+ if (autoplay) {
575
+ params.set('autoplay', 'true');
576
+ // Browsers only permit autoplay without a gesture while muted, and
577
+ // Stream's player follows the same rule.
578
+ params.set('muted', 'true');
579
+ }
580
+ if (options.muted && !autoplay)
581
+ params.set('muted', 'true');
582
+ if (options.loop)
583
+ params.set('loop', 'true');
584
+ if (options.controls === false)
585
+ params.set('controls', 'false');
586
+ if (options.preload)
587
+ params.set('preload', options.preload);
588
+ if (options.poster)
589
+ params.set('poster', options.poster);
590
+ if (start !== undefined)
591
+ params.set('startTime', `${Math.floor(start)}s`);
592
+ if (options.primaryColor)
593
+ params.set('primaryColor', options.primaryColor);
594
+ if (options.letterboxColor)
595
+ params.set('letterboxColor', options.letterboxColor);
596
+ if (options.defaultTextTrack)
597
+ params.set('defaultTextTrack', options.defaultTextTrack);
598
+ const query = params.toString();
599
+ return `https://${host}/${videoId}/iframe${query ? `?${query}` : ''}`;
600
+ }
601
+ if (provider === 'youtube') {
602
+ const domain = privacy ? 'www.youtube-nocookie.com' : 'www.youtube.com';
603
+ if (autoplay)
604
+ params.set('autoplay', '1');
605
+ if (start !== undefined)
606
+ params.set('start', String(Math.floor(start)));
607
+ if (!showRelated)
608
+ params.set('rel', '0');
609
+ const query = params.toString();
610
+ return `https://${domain}/embed/${videoId}${query ? `?${query}` : ''}`;
611
+ }
612
+ // Vimeo
613
+ if (autoplay)
614
+ params.set('autoplay', '1');
615
+ if (privacy)
616
+ params.set('dnt', '1');
617
+ if (start !== undefined)
618
+ params.set('#t', `${Math.floor(start)}s`);
619
+ const query = params.toString();
620
+ return `https://player.vimeo.com/video/${videoId}${query ? `?${query}` : ''}`;
621
+ }
622
+ /**
623
+ * Poster image URL for an embedded video, when one can be derived without a
624
+ * network request.
625
+ *
626
+ * YouTube and Cloudflare Stream both expose predictable thumbnail URLs. Vimeo
627
+ * does not — its thumbnails require an oEmbed lookup — so this returns `null`
628
+ * and the caller should supply `poster` explicitly.
629
+ */
630
+ export function getEmbedThumbnail(provider, videoId, account = {}) {
631
+ if (provider === 'youtube') {
632
+ return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
633
+ }
634
+ if (provider === 'cloudflare') {
635
+ return cloudflareStreamThumbnail(videoId, { ...account, time: '1s', height: 720 });
636
+ }
637
+ return null;
638
+ }
639
+ /**
640
+ * Best available thumbnail for a media item, for grids and thumbnail strips.
641
+ *
642
+ * Returns `null` when the item has no visual representation (audio without
643
+ * artwork, video without a poster), so callers can render an icon instead.
644
+ */
645
+ export function getMediaThumbnail(item) {
646
+ if (item.thumbnail)
647
+ return item.thumbnail;
648
+ switch (item.kind) {
649
+ case 'image':
650
+ return item.src;
651
+ case 'video':
652
+ return item.poster ?? null;
653
+ case 'audio':
654
+ return item.artwork ?? null;
655
+ case 'embed':
656
+ return (item.poster ??
657
+ getEmbedThumbnail(item.provider, item.videoId, {
658
+ customerCode: item.customerCode,
659
+ domain: item.domain,
660
+ }));
661
+ }
662
+ }
663
+ /**
664
+ * Accessible name for a media item.
665
+ *
666
+ * Prefers an explicit title, then image alt text, then a positional fallback
667
+ * so a tile is never announced as just "button".
668
+ */
669
+ export function getMediaLabel(item, index) {
670
+ if (item.title)
671
+ return item.title;
672
+ if (item.kind === 'image' && item.alt)
673
+ return item.alt;
674
+ const kindLabel = isSpatialMedia(item) ? '3D tour' : item.kind === 'embed' ? 'video' : item.kind;
675
+ return index === undefined
676
+ ? `Untitled ${kindLabel}`
677
+ : `${kindLabel.charAt(0).toUpperCase()}${kindLabel.slice(1)} ${index + 1}`;
678
+ }
679
+ /** Whether an item is an interactive 3D space rather than something that plays. */
680
+ export function isSpatialMedia(item) {
681
+ return item.kind === 'embed' && item.provider === 'matterport';
682
+ }
683
+ /**
684
+ * Whether an item has a timeline, and so shows a duration badge and play
685
+ * affordance.
686
+ *
687
+ * A Matterport space is embedded like a video but has no timeline — offering
688
+ * it a play button would promise something the player does not do.
689
+ */
690
+ export function isTimeBasedMedia(item) {
691
+ if (isSpatialMedia(item))
692
+ return false;
693
+ return item.kind === 'video' || item.kind === 'audio' || item.kind === 'embed';
694
+ }
695
+ /**
696
+ * Flatten a `TimeRanges` object into plain start/end pairs.
697
+ *
698
+ * `TimeRanges` is an opaque, non-iterable browser type; this makes buffered
699
+ * ranges renderable and testable.
700
+ */
701
+ export function timeRangesToArray(ranges) {
702
+ if (!ranges)
703
+ return [];
704
+ const result = [];
705
+ for (let i = 0; i < ranges.length; i += 1) {
706
+ result.push({ start: ranges.start(i), end: ranges.end(i) });
707
+ }
708
+ return result;
709
+ }
710
+ /** Clamp a number into an inclusive range. */
711
+ export function clamp(value, min, max) {
712
+ if (Number.isNaN(value))
713
+ return min;
714
+ return Math.min(Math.max(value, min), max);
715
+ }