@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.
- 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/DataPanel.svelte +20 -19
- 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
|
@@ -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
|
-
|
|
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;
|