@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
@@ -5,15 +5,20 @@
5
5
  * Features:
6
6
  * - Drag and drop zone with visual feedback
7
7
  * - Click to browse files
8
- * - File preview (thumbnails for images, icons for other types)
8
+ * - File preview (thumbnails for images and video, icons for other types)
9
+ * - Duration read from audio and video files
10
+ * - List or grid preview layout, with optional lightbox preview
9
11
  * - Size and type validation
10
12
  * - Multiple file support
11
13
  * - Remove file action
12
14
  * - Accessible with keyboard navigation
13
15
  */
14
- import type { FileMetadata } from '../types/components.js';
16
+ import type { FileMetadata, MediaItem } from '../types/components.js';
15
17
  import { cn } from '../utils.js';
18
+ import { formatDuration } from '../utils/media.js';
16
19
  import Button from './Button.svelte';
20
+ import Lightbox from './media/Lightbox.svelte';
21
+ import MediaGrid from './media/MediaGrid.svelte';
17
22
 
18
23
  interface Props {
19
24
  /** Input ID */
@@ -44,6 +49,16 @@
44
49
  * (e.g. "Images, PDF, Videos") instead of showing raw MIME types.
45
50
  */
46
51
  typesLabel?: string;
52
+ /**
53
+ * How uploaded files are previewed. `list` (the default) keeps the
54
+ * existing row layout; `grid` shows media thumbnails.
55
+ */
56
+ previewLayout?: 'list' | 'grid';
57
+ /**
58
+ * Open a lightbox when a preview is activated. Off by default so existing
59
+ * consumers keep their current behaviour.
60
+ */
61
+ preview?: boolean;
47
62
  /** Additional classes */
48
63
  class?: string;
49
64
  }
@@ -64,12 +79,16 @@
64
79
  label,
65
80
  hint,
66
81
  typesLabel,
82
+ previewLayout = 'list',
83
+ preview = false,
67
84
  class: className,
68
85
  }: Props = $props();
69
86
 
70
87
  let isDragging = $state(false);
71
88
  let internalError = $state<string | undefined>(undefined);
72
89
  let fileInput: HTMLInputElement;
90
+ let lightboxOpen = $state(false);
91
+ let lightboxIndex = $state(0);
73
92
 
74
93
  /** Map of file IDs to their AbortControllers for cancellation */
75
94
  const uploadControllers = new Map<string, AbortController>();
@@ -149,6 +168,100 @@
149
168
  return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z';
150
169
  }
151
170
 
171
+ /**
172
+ * Capture a poster frame and the duration from a video file.
173
+ *
174
+ * The frame is taken slightly after the start, because seeking to exactly 0
175
+ * often yields a black frame. Resolves with whatever it managed to read, so
176
+ * an unsupported codec degrades to an icon rather than failing the upload.
177
+ */
178
+ function readVideoPreview(
179
+ file: File
180
+ ): Promise<{ url?: string; duration?: number; width?: number; height?: number }> {
181
+ return new Promise((resolve) => {
182
+ const objectUrl = URL.createObjectURL(file);
183
+ const video = document.createElement('video');
184
+ let settled = false;
185
+
186
+ const finish = (result: {
187
+ url?: string;
188
+ duration?: number;
189
+ width?: number;
190
+ height?: number;
191
+ }) => {
192
+ if (settled) return;
193
+ settled = true;
194
+ clearTimeout(timeout);
195
+ URL.revokeObjectURL(objectUrl);
196
+ resolve(result);
197
+ };
198
+
199
+ // A file the browser cannot decode may never fire either event.
200
+ const timeout = setTimeout(() => finish({}), 5000);
201
+
202
+ video.preload = 'metadata';
203
+ video.muted = true;
204
+ video.playsInline = true;
205
+
206
+ video.onloadedmetadata = () => {
207
+ // Seek a fraction in; frame 0 is frequently blank.
208
+ video.currentTime = Math.min(0.1, video.duration / 2 || 0);
209
+ };
210
+
211
+ video.onseeked = () => {
212
+ const canvas = document.createElement('canvas');
213
+ canvas.width = video.videoWidth;
214
+ canvas.height = video.videoHeight;
215
+ const context = canvas.getContext('2d');
216
+
217
+ let url: string | undefined;
218
+ if (context && canvas.width > 0 && canvas.height > 0) {
219
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
220
+ try {
221
+ url = canvas.toDataURL('image/jpeg', 0.7);
222
+ } catch {
223
+ // Tainted canvas or an unsupported format — fall back to no poster.
224
+ }
225
+ }
226
+
227
+ finish({
228
+ url,
229
+ duration: Number.isFinite(video.duration) ? video.duration : undefined,
230
+ width: video.videoWidth || undefined,
231
+ height: video.videoHeight || undefined,
232
+ });
233
+ };
234
+
235
+ video.onerror = () => finish({});
236
+ video.src = objectUrl;
237
+ });
238
+ }
239
+
240
+ /** Read the duration from an audio file without loading the whole thing. */
241
+ function readAudioDuration(file: File): Promise<number | undefined> {
242
+ return new Promise((resolve) => {
243
+ const objectUrl = URL.createObjectURL(file);
244
+ const audio = document.createElement('audio');
245
+ let settled = false;
246
+
247
+ const finish = (duration?: number) => {
248
+ if (settled) return;
249
+ settled = true;
250
+ clearTimeout(timeout);
251
+ URL.revokeObjectURL(objectUrl);
252
+ resolve(duration);
253
+ };
254
+
255
+ const timeout = setTimeout(() => finish(undefined), 5000);
256
+
257
+ audio.preload = 'metadata';
258
+ audio.onloadedmetadata = () =>
259
+ finish(Number.isFinite(audio.duration) ? audio.duration : undefined);
260
+ audio.onerror = () => finish(undefined);
261
+ audio.src = objectUrl;
262
+ });
263
+ }
264
+
152
265
  // Create FileMetadata from File
153
266
  async function createFileMetadata(file: File): Promise<FileMetadata> {
154
267
  const metadata: FileMetadata = {
@@ -167,6 +280,14 @@
167
280
  reader.onload = () => resolve(reader.result as string);
168
281
  reader.readAsDataURL(file);
169
282
  });
283
+ } else if (file.type.startsWith('video/')) {
284
+ const { url, duration, width, height } = await readVideoPreview(file);
285
+ metadata.url = url;
286
+ metadata.duration = duration;
287
+ metadata.width = width;
288
+ metadata.height = height;
289
+ } else if (file.type.startsWith('audio/')) {
290
+ metadata.duration = await readAudioDuration(file);
170
291
  }
171
292
 
172
293
  return metadata;
@@ -316,6 +437,68 @@
316
437
  }
317
438
  }
318
439
 
440
+ /**
441
+ * Uploaded files mapped to media items for the grid and lightbox previews.
442
+ * Files still uploading, or with no preview URL, are skipped so the gallery
443
+ * never shows a broken tile.
444
+ */
445
+ const previewItems = $derived.by<MediaItem[]>(() =>
446
+ files.flatMap((file): MediaItem[] => {
447
+ if (file.error) return [];
448
+
449
+ if (file.contentType.startsWith('image/') && file.url) {
450
+ return [
451
+ {
452
+ kind: 'image',
453
+ id: file.id,
454
+ src: file.url,
455
+ alt: file.filename,
456
+ title: file.filename,
457
+ width: file.width,
458
+ height: file.height,
459
+ },
460
+ ];
461
+ }
462
+
463
+ if (file.contentType.startsWith('video/') && file.url) {
464
+ return [
465
+ {
466
+ kind: 'video',
467
+ id: file.id,
468
+ src: file.url,
469
+ poster: file.url,
470
+ thumbnail: file.url,
471
+ title: file.filename,
472
+ duration: file.duration,
473
+ },
474
+ ];
475
+ }
476
+
477
+ if (file.contentType.startsWith('audio/') && file.url) {
478
+ return [
479
+ {
480
+ kind: 'audio',
481
+ id: file.id,
482
+ src: file.url,
483
+ title: file.filename,
484
+ duration: file.duration,
485
+ },
486
+ ];
487
+ }
488
+
489
+ return [];
490
+ })
491
+ );
492
+
493
+ const usePreviewGrid = $derived(previewLayout === 'grid' && previewItems.length > 0);
494
+
495
+ function openPreview(fileId: string) {
496
+ const previewIndex = previewItems.findIndex((item) => item.id === fileId);
497
+ if (previewIndex === -1) return;
498
+ lightboxIndex = previewIndex;
499
+ lightboxOpen = true;
500
+ }
501
+
319
502
  // Handle keyboard activation
320
503
  function handleKeyDown(e: KeyboardEvent) {
321
504
  if (e.key === 'Enter' || e.key === ' ') {
@@ -400,6 +583,18 @@
400
583
  </p>
401
584
  {/if}
402
585
 
586
+ <!-- Grid preview: media thumbnails, with the removable file rows kept below -->
587
+ {#if usePreviewGrid}
588
+ <MediaGrid
589
+ items={previewItems}
590
+ label="Uploaded media"
591
+ columns={{ base: 3, sm: 4, md: 5 }}
592
+ gap="sm"
593
+ lightbox={preview}
594
+ class="mt-4"
595
+ />
596
+ {/if}
597
+
403
598
  <!-- File List -->
404
599
  {#if files.length > 0}
405
600
  <ul class="mt-4 space-y-2" aria-label="Uploaded files">
@@ -412,8 +607,19 @@
412
607
  >
413
608
  <!-- Preview/Icon -->
414
609
  <div class="flex-shrink-0">
415
- {#if file.url && file.contentType.startsWith('image/')}
416
- <img src={file.url} alt={file.filename} class="h-10 w-10 rounded object-cover" />
610
+ {#if file.url && (file.contentType.startsWith('image/') || file.contentType.startsWith('video/'))}
611
+ {#if preview}
612
+ <button
613
+ type="button"
614
+ class="block h-10 w-10 overflow-hidden rounded outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
615
+ onclick={() => openPreview(file.id)}
616
+ aria-label={`Preview ${file.filename}`}
617
+ >
618
+ <img src={file.url} alt="" class="h-full w-full object-cover" />
619
+ </button>
620
+ {:else}
621
+ <img src={file.url} alt={file.filename} class="h-10 w-10 rounded object-cover" />
622
+ {/if}
417
623
  {:else}
418
624
  <div class="flex h-10 w-10 items-center justify-center rounded bg-muted">
419
625
  <svg
@@ -438,6 +644,9 @@
438
644
  <p class="truncate text-sm font-medium">{file.filename}</p>
439
645
  <p class="text-xs text-muted-foreground">
440
646
  {formatSize(file.size)}
647
+ {#if file.duration !== undefined}
648
+ <span class="tabular-nums"> · {formatDuration(file.duration)}</span>
649
+ {/if}
441
650
  {#if file.error}
442
651
  <span class="text-destructive"> - {file.error}</span>
443
652
  {/if}
@@ -477,4 +686,12 @@
477
686
  {/each}
478
687
  </ul>
479
688
  {/if}
689
+
690
+ <!--
691
+ Only mounted in list mode; the grid preview already carries its own
692
+ lightbox, and two would fight over focus.
693
+ -->
694
+ {#if preview && !usePreviewGrid && previewItems.length > 0}
695
+ <Lightbox items={previewItems} bind:open={lightboxOpen} bind:index={lightboxIndex} />
696
+ {/if}
480
697
  </div>
@@ -4,7 +4,9 @@
4
4
  * Features:
5
5
  * - Drag and drop zone with visual feedback
6
6
  * - Click to browse files
7
- * - File preview (thumbnails for images, icons for other types)
7
+ * - File preview (thumbnails for images and video, icons for other types)
8
+ * - Duration read from audio and video files
9
+ * - List or grid preview layout, with optional lightbox preview
8
10
  * - Size and type validation
9
11
  * - Multiple file support
10
12
  * - Remove file action
@@ -40,6 +42,16 @@ interface Props {
40
42
  * (e.g. "Images, PDF, Videos") instead of showing raw MIME types.
41
43
  */
42
44
  typesLabel?: string;
45
+ /**
46
+ * How uploaded files are previewed. `list` (the default) keeps the
47
+ * existing row layout; `grid` shows media thumbnails.
48
+ */
49
+ previewLayout?: 'list' | 'grid';
50
+ /**
51
+ * Open a lightbox when a preview is activated. Off by default so existing
52
+ * consumers keep their current behaviour.
53
+ */
54
+ preview?: boolean;
43
55
  /** Additional classes */
44
56
  class?: string;
45
57
  }
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ /**
3
+ * AspectRatio - Constrains its content to a fixed width/height ratio
4
+ *
5
+ * Features:
6
+ * - Named ratios (square, video, photo, portrait, wide) or any custom number
7
+ * - Reserves layout space before media loads, preventing cumulative layout shift
8
+ * - Content fills the box, so `object-cover` media crops rather than stretches
9
+ */
10
+ import { AspectRatio as AspectRatioPrimitive } from 'bits-ui';
11
+ import type { Snippet } from 'svelte';
12
+ import type { MediaAspectRatio } from '../../types/components.js';
13
+ import { cn } from '../../utils.js';
14
+ import { resolveAspectRatio } from '../../utils/media.js';
15
+
16
+ interface Props {
17
+ /**
18
+ * Width-to-height ratio. Accepts a named ratio or a raw number.
19
+ *
20
+ * @example ratio="video" // 16 / 9
21
+ * @example ratio={2.35} // cinemascope
22
+ */
23
+ ratio?: MediaAspectRatio | number;
24
+ /** Additional classes applied to the content box */
25
+ class?: string;
26
+ /** Content stretched to fill the ratio box */
27
+ children: Snippet;
28
+ /** Any other attribute is forwarded to the content box */
29
+ [key: string]: unknown;
30
+ }
31
+
32
+ let { ratio = 'video', class: className, children, ...restProps }: Props = $props();
33
+
34
+ const numericRatio = $derived(resolveAspectRatio(ratio));
35
+ </script>
36
+
37
+ <!--
38
+ The primitive puts its class on the inner, absolutely-inset element, while
39
+ the element that actually carries the size is its outer wrapper. Passing
40
+ `class` straight through would therefore silently drop any sizing the caller
41
+ asked for, so the caller's classes go on a wrapper of our own instead.
42
+ -->
43
+ <div class={cn('relative overflow-hidden', className)}>
44
+ <AspectRatioPrimitive.Root ratio={numericRatio} {...restProps}>
45
+ {@render children()}
46
+ </AspectRatioPrimitive.Root>
47
+ </div>
@@ -0,0 +1,20 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { MediaAspectRatio } from '../../types/components.js';
3
+ interface Props {
4
+ /**
5
+ * Width-to-height ratio. Accepts a named ratio or a raw number.
6
+ *
7
+ * @example ratio="video" // 16 / 9
8
+ * @example ratio={2.35} // cinemascope
9
+ */
10
+ ratio?: MediaAspectRatio | number;
11
+ /** Additional classes applied to the content box */
12
+ class?: string;
13
+ /** Content stretched to fill the ratio box */
14
+ children: Snippet;
15
+ /** Any other attribute is forwarded to the content box */
16
+ [key: string]: unknown;
17
+ }
18
+ declare const AspectRatio: import("svelte").Component<Props, {}, "">;
19
+ type AspectRatio = ReturnType<typeof AspectRatio>;
20
+ export default AspectRatio;
@@ -0,0 +1,247 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Audio - Audio player with themed controls
4
+ *
5
+ * Features:
6
+ * - Compact inline bar: artwork, title/artist, play/pause, seek, volume, speed
7
+ * - Full keyboard control (Space/K, arrows, J/L, M, Home/End, 0-9)
8
+ * - Buffering spinner and an inline error state with retry
9
+ *
10
+ * @example
11
+ * ```svelte
12
+ * <Audio src="/episode.mp3" title="Episode 12" artist="Design Notes" />
13
+ * ```
14
+ */
15
+ import { useMediaPlayer } from '../../composables/useMediaPlayer.svelte.js';
16
+ import type { MediaSource, MediaTextTrack } from '../../types/components.js';
17
+ import { cn } from '../../utils.js';
18
+ import { toMediaSources } from '../../utils/media.js';
19
+ import Icon from '../Icon.svelte';
20
+ import Spinner from '../Spinner.svelte';
21
+ import MediaControls from './internal/MediaControls.svelte';
22
+
23
+ interface Props {
24
+ /** A single URL, or several sources for format fallback */
25
+ src: string | MediaSource[];
26
+ /** Track title shown above the controls */
27
+ title?: string;
28
+ /** Performer or author shown beside the title */
29
+ artist?: string;
30
+ /** Artwork image URL shown to the left of the controls */
31
+ artwork?: string;
32
+ /** Accessible name for the player region (defaults to the title) */
33
+ label?: string;
34
+ /** Transcript or caption tracks */
35
+ tracks?: MediaTextTrack[];
36
+ /** Start playing automatically — browsers block this unless muted */
37
+ autoplay?: boolean;
38
+ /** Start muted */
39
+ muted?: boolean;
40
+ /** Loop playback */
41
+ loop?: boolean;
42
+ /** Initial volume, 0–1 */
43
+ volume?: number;
44
+ /** How much to preload */
45
+ preload?: 'none' | 'metadata' | 'auto';
46
+ /** Called when playback ends */
47
+ onEnded?: () => void;
48
+ /** Called on each time update, with seconds elapsed */
49
+ onTimeUpdate?: (currentTime: number) => void;
50
+ /** Additional classes */
51
+ class?: string;
52
+ }
53
+
54
+ let {
55
+ src,
56
+ title,
57
+ artist,
58
+ artwork,
59
+ label,
60
+ tracks = [],
61
+ autoplay = false,
62
+ muted = false,
63
+ loop = false,
64
+ volume = 1,
65
+ preload = 'metadata',
66
+ onEnded,
67
+ onTimeUpdate,
68
+ class: className,
69
+ }: Props = $props();
70
+
71
+ let audioElement = $state<HTMLAudioElement | null>(null);
72
+
73
+ const player = useMediaPlayer(() => audioElement, {
74
+ volume: () => volume,
75
+ muted: () => muted,
76
+ onEnded: () => onEnded?.(),
77
+ onTimeUpdate: (currentTime) => onTimeUpdate?.(currentTime),
78
+ });
79
+
80
+ const sources = $derived(toMediaSources(src));
81
+ const regionLabel = $derived(label ?? (title ? `Audio player: ${title}` : 'Audio player'));
82
+
83
+ function isTextEntry(target: EventTarget | null): boolean {
84
+ if (!(target instanceof HTMLElement)) return false;
85
+ return (
86
+ target.isContentEditable ||
87
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) ||
88
+ target.getAttribute('role') === 'slider'
89
+ );
90
+ }
91
+
92
+ function handleKeyDown(event: KeyboardEvent) {
93
+ if (isTextEntry(event.target) || event.metaKey || event.ctrlKey || event.altKey) return;
94
+
95
+ switch (event.key) {
96
+ case ' ':
97
+ case 'k':
98
+ case 'K':
99
+ event.preventDefault();
100
+ player.toggle();
101
+ break;
102
+ case 'ArrowLeft':
103
+ event.preventDefault();
104
+ player.seekBy(-5);
105
+ break;
106
+ case 'ArrowRight':
107
+ event.preventDefault();
108
+ player.seekBy(5);
109
+ break;
110
+ case 'j':
111
+ case 'J':
112
+ event.preventDefault();
113
+ player.seekBy(-10);
114
+ break;
115
+ case 'l':
116
+ case 'L':
117
+ event.preventDefault();
118
+ player.seekBy(10);
119
+ break;
120
+ case 'ArrowUp':
121
+ event.preventDefault();
122
+ player.adjustVolume(0.1);
123
+ break;
124
+ case 'ArrowDown':
125
+ event.preventDefault();
126
+ player.adjustVolume(-0.1);
127
+ break;
128
+ case 'm':
129
+ case 'M':
130
+ event.preventDefault();
131
+ player.toggleMute();
132
+ break;
133
+ case 'Home':
134
+ event.preventDefault();
135
+ player.seek(0);
136
+ break;
137
+ case 'End':
138
+ event.preventDefault();
139
+ player.seek(player.duration);
140
+ break;
141
+ default:
142
+ if (/^[0-9]$/.test(event.key)) {
143
+ event.preventDefault();
144
+ player.seekToFraction(Number(event.key) / 10);
145
+ }
146
+ }
147
+ }
148
+
149
+ function retry() {
150
+ audioElement?.load();
151
+ }
152
+ </script>
153
+
154
+ <!--
155
+ `tabindex="0"` is deliberate: the player region has to be reachable so the
156
+ playback shortcuts below work.
157
+ -->
158
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
159
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
160
+ <div
161
+ role="region"
162
+ aria-label={regionLabel}
163
+ tabindex="0"
164
+ class={cn(
165
+ 'flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-card-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
166
+ className
167
+ )}
168
+ onkeydown={handleKeyDown}
169
+ >
170
+ {#if artwork}
171
+ <img
172
+ src={artwork}
173
+ alt=""
174
+ aria-hidden="true"
175
+ class="h-14 w-14 shrink-0 rounded-md object-cover"
176
+ />
177
+ {:else}
178
+ <div
179
+ class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
180
+ aria-hidden="true"
181
+ >
182
+ <Icon name="music" size={24} />
183
+ </div>
184
+ {/if}
185
+
186
+ <div class="min-w-0 flex-1">
187
+ {#if title || artist}
188
+ <div class="mb-0.5 flex min-w-0 items-baseline gap-2 px-3">
189
+ {#if title}
190
+ <span class="truncate text-sm font-medium">{title}</span>
191
+ {/if}
192
+ {#if artist}
193
+ <span class="truncate text-xs text-muted-foreground">{artist}</span>
194
+ {/if}
195
+ </div>
196
+ {/if}
197
+
198
+ {#if player.error}
199
+ <div class="flex items-center gap-3 px-3 py-2 text-sm text-destructive" role="alert">
200
+ <span>This audio could not be played.</span>
201
+ <button
202
+ type="button"
203
+ class="rounded-md border border-border px-2 py-1 text-xs font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
204
+ onclick={retry}
205
+ >
206
+ Try again
207
+ </button>
208
+ </div>
209
+ {:else}
210
+ <div class="relative">
211
+ <MediaControls {player} variant="audio" class="px-0 py-0" />
212
+ {#if player.buffering}
213
+ <div
214
+ class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2"
215
+ aria-hidden="true"
216
+ >
217
+ <Spinner size="sm" />
218
+ </div>
219
+ {/if}
220
+ </div>
221
+ {/if}
222
+ </div>
223
+
224
+ <audio
225
+ bind:this={audioElement}
226
+ {autoplay}
227
+ {loop}
228
+ {muted}
229
+ {preload}
230
+ controls={false}
231
+ class="sr-only"
232
+ >
233
+ {#each sources as source (source.src)}
234
+ <source src={source.src} type={source.type} />
235
+ {/each}
236
+ {#each tracks as track (track.src)}
237
+ <track
238
+ src={track.src}
239
+ kind={track.kind ?? 'captions'}
240
+ srclang={track.srclang}
241
+ label={track.label}
242
+ default={track.default}
243
+ />
244
+ {/each}
245
+ Your browser does not support embedded audio.
246
+ </audio>
247
+ </div>
@@ -0,0 +1,34 @@
1
+ import type { MediaSource, MediaTextTrack } from '../../types/components.js';
2
+ interface Props {
3
+ /** A single URL, or several sources for format fallback */
4
+ src: string | MediaSource[];
5
+ /** Track title shown above the controls */
6
+ title?: string;
7
+ /** Performer or author shown beside the title */
8
+ artist?: string;
9
+ /** Artwork image URL shown to the left of the controls */
10
+ artwork?: string;
11
+ /** Accessible name for the player region (defaults to the title) */
12
+ label?: string;
13
+ /** Transcript or caption tracks */
14
+ tracks?: MediaTextTrack[];
15
+ /** Start playing automatically — browsers block this unless muted */
16
+ autoplay?: boolean;
17
+ /** Start muted */
18
+ muted?: boolean;
19
+ /** Loop playback */
20
+ loop?: boolean;
21
+ /** Initial volume, 0–1 */
22
+ volume?: number;
23
+ /** How much to preload */
24
+ preload?: 'none' | 'metadata' | 'auto';
25
+ /** Called when playback ends */
26
+ onEnded?: () => void;
27
+ /** Called on each time update, with seconds elapsed */
28
+ onTimeUpdate?: (currentTime: number) => void;
29
+ /** Additional classes */
30
+ class?: string;
31
+ }
32
+ declare const Audio: import("svelte").Component<Props, {}, "">;
33
+ type Audio = ReturnType<typeof Audio>;
34
+ export default Audio;