@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,379 @@
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
+ import type { CloudflareStreamAccount, EmbedMediaItem, MatterportPlayerOptions, MediaAspectRatio, MediaEmbedProvider, MediaItem, MediaKind, MediaSource, MediaTextTrack, VideoMediaItem } from '../types/components.js';
8
+ /** Numeric width/height ratios for the named aspect ratios. */
9
+ export declare const ASPECT_RATIOS: Record<MediaAspectRatio, number>;
10
+ /**
11
+ * Resolve a ratio prop to a numeric width/height ratio.
12
+ *
13
+ * @example
14
+ * resolveAspectRatio('video') // 1.777…
15
+ * resolveAspectRatio(2.35) // 2.35
16
+ */
17
+ export declare function resolveAspectRatio(ratio: MediaAspectRatio | number): number;
18
+ /**
19
+ * Format a duration in seconds as `m:ss`, or `h:mm:ss` past an hour.
20
+ *
21
+ * Non-finite and negative inputs collapse to `0:00` so a player showing
22
+ * `NaN` before metadata loads is impossible.
23
+ *
24
+ * @example
25
+ * formatDuration(83) // '1:23'
26
+ * formatDuration(3723) // '1:02:03'
27
+ * formatDuration(NaN) // '0:00'
28
+ */
29
+ export declare function formatDuration(seconds: number): string;
30
+ /**
31
+ * Spoken form of a duration, for `aria-label` and `aria-valuetext`.
32
+ *
33
+ * Screen readers announce `1:23` as "one twenty-three", which is ambiguous.
34
+ *
35
+ * @example
36
+ * formatDurationSpoken(83) // '1 minute 23 seconds'
37
+ */
38
+ export declare function formatDurationSpoken(seconds: number): string;
39
+ /**
40
+ * Extract the lower-cased file extension from a URL, ignoring query strings
41
+ * and hash fragments.
42
+ */
43
+ export declare function getFileExtension(url: string): string;
44
+ /**
45
+ * Infer a media kind from a URL or MIME type.
46
+ *
47
+ * MIME type wins when supplied, since a URL may have no extension at all.
48
+ * Returns `null` when nothing can be inferred, so callers can decide whether
49
+ * to fall back or warn.
50
+ *
51
+ * @example
52
+ * inferMediaKind('/clip.mp4') // 'video'
53
+ * inferMediaKind('/asset', 'image/png') // 'image'
54
+ * inferMediaKind('https://youtu.be/dQw4w9Wg') // 'embed'
55
+ */
56
+ export declare function inferMediaKind(url: string, mimeType?: string): MediaKind | null;
57
+ /** MIME type browsers use for HLS playlists. */
58
+ export declare const HLS_MIME_TYPE = "application/vnd.apple.mpegurl";
59
+ /** MIME type browsers use for MPEG-DASH manifests. */
60
+ export declare const DASH_MIME_TYPE = "application/dash+xml";
61
+ /**
62
+ * Whether a source is an HLS playlist.
63
+ *
64
+ * Checks the declared MIME type first, then the `.m3u8` extension, so a
65
+ * manifest served from an extensionless or query-laden URL still resolves
66
+ * correctly as long as its type is declared.
67
+ */
68
+ export declare function isHlsSource(source: string | MediaSource): boolean;
69
+ /** Normalize a `src` prop to the list of sources a `<video>`/`<audio>` should render. */
70
+ export declare function toMediaSources(src: string | MediaSource[]): MediaSource[];
71
+ /** Best-effort MIME type for a media file extension. */
72
+ export declare function guessMimeType(extension: string): string | undefined;
73
+ /**
74
+ * Cloudflare Stream
75
+ *
76
+ * Stream serves every asset for a video from one host, keyed by the video UID.
77
+ * A signed playback token substitutes for the UID in exactly the same
78
+ * position, so private videos need no separate code path — pass the token as
79
+ * `videoId`.
80
+ *
81
+ * @see https://developers.cloudflare.com/stream/viewing-videos/using-own-player/
82
+ */
83
+ /** Resolve the host serving a Stream account's assets. */
84
+ export declare function cloudflareStreamHost(account: CloudflareStreamAccount): string | null;
85
+ /** Options for a Stream thumbnail image. */
86
+ export interface CloudflareStreamThumbnailOptions extends CloudflareStreamAccount {
87
+ /** Timestamp to grab, e.g. `1s` or `5m2s` (Stream's default is `0s`) */
88
+ time?: string;
89
+ /** Height in pixels (Stream's default is 640) */
90
+ height?: number;
91
+ /** Width in pixels (Stream's default is 640) */
92
+ width?: number;
93
+ /** How the frame is fitted to the requested box */
94
+ fit?: 'crop' | 'clip' | 'scale' | 'fill';
95
+ /** Return an animated GIF instead of a still JPEG */
96
+ animated?: boolean;
97
+ /** GIF length, e.g. `4s` (animated only) */
98
+ duration?: string;
99
+ /** GIF frame rate (animated only) */
100
+ fps?: number;
101
+ }
102
+ /**
103
+ * Thumbnail URL for a Cloudflare Stream video.
104
+ *
105
+ * Unlike Vimeo, Stream generates thumbnails on demand from a predictable URL,
106
+ * so a poster never needs a network lookup to discover.
107
+ *
108
+ * @example
109
+ * cloudflareStreamThumbnail('a1b2c3', { customerCode: 'abc', time: '1s', height: 600 })
110
+ * // 'https://customer-abc.cloudflarestream.com/a1b2c3/thumbnails/thumbnail.jpg?time=1s&height=600'
111
+ */
112
+ export declare function cloudflareStreamThumbnail(videoId: string, options: CloudflareStreamThumbnailOptions): string | null;
113
+ /** Options for building Stream playback URLs. */
114
+ export interface CloudflareStreamUrlOptions extends CloudflareStreamAccount {
115
+ /** Request the low-latency HLS variant */
116
+ lowLatency?: boolean;
117
+ /** Bandwidth hint in Mbps, passed to Stream as `clientBandwidthHint` */
118
+ clientBandwidthHint?: number;
119
+ }
120
+ /** Every playback URL for a Cloudflare Stream video. */
121
+ export interface CloudflareStreamUrls {
122
+ /** HLS playlist — the source our own player uses */
123
+ hls: string;
124
+ /** DASH manifest, for players that prefer it */
125
+ dash: string;
126
+ /** Cloudflare's own player, for use in an iframe */
127
+ iframe: string;
128
+ /** Still thumbnail */
129
+ thumbnail: string;
130
+ /** Pre-encoded MP4, available only when downloads are enabled on the video */
131
+ download: string;
132
+ }
133
+ /**
134
+ * Build the playback URLs for a Cloudflare Stream video.
135
+ *
136
+ * Returns `null` when the account cannot be resolved, so a missing customer
137
+ * code surfaces as a clear failure rather than a malformed request.
138
+ *
139
+ * @example
140
+ * cloudflareStreamUrls('a1b2c3', { customerCode: 'abc' })?.hls
141
+ * // 'https://customer-abc.cloudflarestream.com/a1b2c3/manifest/video.m3u8'
142
+ */
143
+ export declare function cloudflareStreamUrls(videoId: string, options: CloudflareStreamUrlOptions): CloudflareStreamUrls | null;
144
+ /** Options for {@link cloudflareStream}. */
145
+ export interface CloudflareStreamItemOptions extends CloudflareStreamUrlOptions {
146
+ /** Item id; defaults to the video id */
147
+ id?: string;
148
+ /** Title shown in players, grids, and the lightbox */
149
+ title?: string;
150
+ /** Longer caption shown beneath the video */
151
+ caption?: string;
152
+ /** Known duration in seconds, shown before metadata loads */
153
+ duration?: number;
154
+ /** Override the derived poster */
155
+ poster?: string;
156
+ /** Timestamp used for the derived poster, e.g. `1s` */
157
+ posterTime?: string;
158
+ /** Height of the derived poster in pixels */
159
+ posterHeight?: number;
160
+ /**
161
+ * Caption tracks. Stream embeds its own captions in the HLS playlist, so
162
+ * these are only needed for tracks hosted elsewhere.
163
+ */
164
+ tracks?: MediaTextTrack[];
165
+ /** Offer a download link (only works when downloads are enabled on the video) */
166
+ downloadable?: boolean;
167
+ }
168
+ /**
169
+ * Build a media item for a Cloudflare Stream video, playable by `Video`,
170
+ * `MediaGrid`, `MediaCarousel`, and `Lightbox`.
171
+ *
172
+ * The item's source is the HLS playlist, so playback uses the design system's
173
+ * own controls rather than Cloudflare's player. Use `MediaEmbed` with
174
+ * `provider="cloudflare"` when you want Cloudflare's player instead.
175
+ *
176
+ * Throws when the account cannot be resolved, because every URL on the
177
+ * returned item would otherwise be malformed.
178
+ *
179
+ * @example
180
+ * ```svelte
181
+ * <script lang="ts">
182
+ * import { Video, cloudflareStream } from '@classic-homes/theme-svelte';
183
+ *
184
+ * const tour = cloudflareStream('a1b2c3', {
185
+ * customerCode: 'abc123',
186
+ * title: 'Product tour',
187
+ * });
188
+ * </script>
189
+ *
190
+ * <Video item={tour} />
191
+ * ```
192
+ */
193
+ export declare function cloudflareStream(videoId: string, options: CloudflareStreamItemOptions): VideoMediaItem;
194
+ /**
195
+ * Build the showcase URL for a Matterport space.
196
+ *
197
+ * Matterport's own defaults are left alone unless an option asks otherwise,
198
+ * so the embed looks the way the space's owner configured it.
199
+ *
200
+ * @example
201
+ * buildMatterportSrc('SxQL3iGyoDo', { brand: false })
202
+ * // 'https://my.matterport.com/show/?m=SxQL3iGyoDo&qs=1&brand=0'
203
+ */
204
+ export declare function buildMatterportSrc(modelId: string, options?: MatterportPlayerOptions): string;
205
+ /** The public metadata Matterport exposes for a space. */
206
+ export interface MatterportMetadata {
207
+ /** The space's name, as set by its owner */
208
+ name: string;
209
+ /** Preview image URL. Signed and time-limited, so it cannot be cached long. */
210
+ image: string | null;
211
+ }
212
+ /**
213
+ * Look up a Matterport space's name and preview image.
214
+ *
215
+ * Matterport publishes no oEmbed endpoint and no derivable thumbnail URL, so
216
+ * a real preview needs this request. It is never made automatically — the
217
+ * caller opts in — because contacting the provider before the viewer clicks is
218
+ * exactly what the lazy facade exists to avoid.
219
+ *
220
+ * The returned image URL is signed and short-lived, so it must be used
221
+ * promptly rather than stored.
222
+ *
223
+ * Resolves to `null` on any failure rather than throwing, so a preview is
224
+ * always an enhancement and never breaks a render.
225
+ */
226
+ export declare function fetchMatterportMetadata(modelId: string, options?: {
227
+ signal?: AbortSignal;
228
+ }): Promise<MatterportMetadata | null>;
229
+ /** Options for {@link matterportTour}. */
230
+ export interface MatterportTourOptions extends MatterportPlayerOptions {
231
+ /** Item id; defaults to the model id */
232
+ id?: string;
233
+ /** Title shown in players, grids, and the lightbox */
234
+ title?: string;
235
+ /** Longer caption shown beneath the tour */
236
+ caption?: string;
237
+ /** Preview image. Matterport publishes none, so supply one for a real thumbnail. */
238
+ poster?: string;
239
+ }
240
+ /**
241
+ * Build a media item for a Matterport space, usable by `MediaEmbed`,
242
+ * `MediaGrid`, `MediaCarousel`, and `Lightbox`.
243
+ *
244
+ * @example
245
+ * ```svelte
246
+ * <script lang="ts">
247
+ * import { MediaGrid, matterportTour } from '@classic-homes/theme-svelte';
248
+ *
249
+ * const items = [matterportTour('SxQL3iGyoDo', { title: 'Show home' })];
250
+ * </script>
251
+ *
252
+ * <MediaGrid {items} />
253
+ * ```
254
+ */
255
+ export declare function matterportTour(modelId: string, options?: MatterportTourOptions): EmbedMediaItem;
256
+ /** A provider/id pair parsed out of an embed URL. */
257
+ export interface ParsedEmbed {
258
+ provider: MediaEmbedProvider;
259
+ videoId: string;
260
+ /** Start offset in seconds, when the URL carried one */
261
+ start?: number;
262
+ /** Stream customer code, when the URL was a Cloudflare Stream URL */
263
+ customerCode?: string;
264
+ /** Host, when the URL used a custom Stream domain */
265
+ domain?: string;
266
+ }
267
+ /**
268
+ * Parse a YouTube, Vimeo, or Cloudflare Stream URL into a provider and video id.
269
+ *
270
+ * Handles `youtube.com/watch?v=`, `youtu.be/`, `/embed/`, `/shorts/`, and
271
+ * `/live/` for YouTube; `vimeo.com/<id>` (including unlisted-hash and
272
+ * `player.vimeo.com/video/<id>` forms) for Vimeo; and every
273
+ * `customer-<CODE>.cloudflarestream.com/<UID>/…` form plus the legacy
274
+ * `videodelivery.net` hosts for Stream. Returns `null` for anything else
275
+ * rather than guessing.
276
+ *
277
+ * @example
278
+ * parseEmbedUrl('https://youtu.be/dQw4w9WgXcQ?t=30')
279
+ * // { provider: 'youtube', videoId: 'dQw4w9WgXcQ', start: 30 }
280
+ *
281
+ * @example
282
+ * parseEmbedUrl('https://customer-abc.cloudflarestream.com/a1b2c3/iframe')
283
+ * // { provider: 'cloudflare', videoId: 'a1b2c3', customerCode: 'abc' }
284
+ */
285
+ export declare function parseEmbedUrl(url: string): ParsedEmbed | null;
286
+ /** Options accepted when building an embed iframe URL. */
287
+ export interface EmbedSrcOptions {
288
+ /** Route YouTube through `youtube-nocookie.com` (default `true`) */
289
+ privacy?: boolean;
290
+ /** Begin playing immediately — set when the user has clicked the facade */
291
+ autoplay?: boolean;
292
+ /** Start offset in seconds */
293
+ start?: number;
294
+ /** Show provider chrome and related-video suggestions (default `false`) */
295
+ showRelated?: boolean;
296
+ /** Start muted (Cloudflare Stream) */
297
+ muted?: boolean;
298
+ /** Loop playback (Cloudflare Stream) */
299
+ loop?: boolean;
300
+ /** Show the provider player's own controls (Cloudflare Stream, default `true`) */
301
+ controls?: boolean;
302
+ /** Preload hint for the provider player (Cloudflare Stream) */
303
+ preload?: 'none' | 'metadata' | 'auto';
304
+ /** Poster image shown by the provider player (Cloudflare Stream) */
305
+ poster?: string;
306
+ /** Accent colour for the player UI, any CSS colour (Cloudflare Stream) */
307
+ primaryColor?: string;
308
+ /** Letterbox/pillarbox colour, any CSS colour (Cloudflare Stream) */
309
+ letterboxColor?: string;
310
+ /** BCP-47 language code for the captions track to enable (Cloudflare Stream) */
311
+ defaultTextTrack?: string;
312
+ /** Stream customer code from the dashboard (Cloudflare Stream) */
313
+ customerCode?: string;
314
+ /** Custom Stream hostname (Cloudflare Stream) */
315
+ domain?: string;
316
+ /** Showcase options (Matterport) */
317
+ matterport?: MatterportPlayerOptions;
318
+ }
319
+ /**
320
+ * Build the iframe `src` for an embedded video.
321
+ *
322
+ * YouTube defaults to the `youtube-nocookie.com` domain, which does not set
323
+ * tracking cookies until playback begins. Cloudflare Stream needs the account
324
+ * to be resolvable, so pass `customerCode` or `domain` for that provider.
325
+ *
326
+ * @example
327
+ * buildEmbedSrc('youtube', 'dQw4w9WgXcQ', { autoplay: true, start: 30 })
328
+ * // 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&start=30&rel=0'
329
+ *
330
+ * @example
331
+ * buildEmbedSrc('cloudflare', 'a1b2c3', { customerCode: 'abc' })
332
+ * // 'https://customer-abc.cloudflarestream.com/a1b2c3/iframe'
333
+ */
334
+ export declare function buildEmbedSrc(provider: MediaEmbedProvider, videoId: string, options?: EmbedSrcOptions): string;
335
+ /**
336
+ * Poster image URL for an embedded video, when one can be derived without a
337
+ * network request.
338
+ *
339
+ * YouTube and Cloudflare Stream both expose predictable thumbnail URLs. Vimeo
340
+ * does not — its thumbnails require an oEmbed lookup — so this returns `null`
341
+ * and the caller should supply `poster` explicitly.
342
+ */
343
+ export declare function getEmbedThumbnail(provider: MediaEmbedProvider, videoId: string, account?: CloudflareStreamAccount): string | null;
344
+ /**
345
+ * Best available thumbnail for a media item, for grids and thumbnail strips.
346
+ *
347
+ * Returns `null` when the item has no visual representation (audio without
348
+ * artwork, video without a poster), so callers can render an icon instead.
349
+ */
350
+ export declare function getMediaThumbnail(item: MediaItem): string | null;
351
+ /**
352
+ * Accessible name for a media item.
353
+ *
354
+ * Prefers an explicit title, then image alt text, then a positional fallback
355
+ * so a tile is never announced as just "button".
356
+ */
357
+ export declare function getMediaLabel(item: MediaItem, index?: number): string;
358
+ /** Whether an item is an interactive 3D space rather than something that plays. */
359
+ export declare function isSpatialMedia(item: MediaItem): boolean;
360
+ /**
361
+ * Whether an item has a timeline, and so shows a duration badge and play
362
+ * affordance.
363
+ *
364
+ * A Matterport space is embedded like a video but has no timeline — offering
365
+ * it a play button would promise something the player does not do.
366
+ */
367
+ export declare function isTimeBasedMedia(item: MediaItem): boolean;
368
+ /**
369
+ * Flatten a `TimeRanges` object into plain start/end pairs.
370
+ *
371
+ * `TimeRanges` is an opaque, non-iterable browser type; this makes buffered
372
+ * ranges renderable and testable.
373
+ */
374
+ export declare function timeRangesToArray(ranges: TimeRanges | null): Array<{
375
+ start: number;
376
+ end: number;
377
+ }>;
378
+ /** Clamp a number into an inclusive range. */
379
+ export declare function clamp(value: number, min: number, max: number): number;