@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,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
|
+
}
|