@classic-homes/theme-svelte 1.2.2 → 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/DataPanel.svelte +8 -0
- package/dist/lib/components/DataPanel.svelte.d.ts +6 -0
- package/dist/lib/components/DataPanelTab.svelte +38 -8
- package/dist/lib/components/DataPanelTab.svelte.d.ts +7 -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,283 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* MediaControls - Shared control bar for the Video and Audio players (internal)
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Play/pause, seek bar, elapsed and total time
|
|
7
|
+
* - Mute toggle with a volume slider that expands on hover or keyboard focus
|
|
8
|
+
* - Playback speed cycling, captions toggle, picture-in-picture, fullscreen
|
|
9
|
+
* - Every control carries a state-aware accessible name
|
|
10
|
+
*
|
|
11
|
+
* Not exported from the package — use `Video` or `Audio` instead.
|
|
12
|
+
*/
|
|
13
|
+
import { Slider as SliderPrimitive } from 'bits-ui';
|
|
14
|
+
import type { Snippet } from 'svelte';
|
|
15
|
+
import type { MediaPlayerApi } from '../../../composables/useMediaPlayer.svelte.js';
|
|
16
|
+
import { cn } from '../../../utils.js';
|
|
17
|
+
import { formatDuration } from '../../../utils/media.js';
|
|
18
|
+
import Icon from '../../Icon.svelte';
|
|
19
|
+
import MediaScrubber from './MediaScrubber.svelte';
|
|
20
|
+
|
|
21
|
+
/** Speeds cycled through by the playback-rate button */
|
|
22
|
+
const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
/** Player API driving the controls */
|
|
26
|
+
player: MediaPlayerApi;
|
|
27
|
+
/** `video` overlays a translucent bar; `audio` renders a solid inline bar */
|
|
28
|
+
variant?: 'video' | 'audio';
|
|
29
|
+
/** Show the mute button and volume slider */
|
|
30
|
+
showVolume?: boolean;
|
|
31
|
+
/** Show the playback-speed button */
|
|
32
|
+
showRate?: boolean;
|
|
33
|
+
/** Show the captions button (also requires at least one text track) */
|
|
34
|
+
showCaptions?: boolean;
|
|
35
|
+
/** Show the picture-in-picture button (video only, where supported) */
|
|
36
|
+
showPictureInPicture?: boolean;
|
|
37
|
+
/** Show the fullscreen button (video only) */
|
|
38
|
+
showFullscreen?: boolean;
|
|
39
|
+
/** Element made fullscreen — normally the player container, not the media element */
|
|
40
|
+
fullscreenTarget?: HTMLElement | null;
|
|
41
|
+
/**
|
|
42
|
+
* Override for the play/pause button, so the player can do extra work on
|
|
43
|
+
* play — releasing a held-back stream loader, for instance.
|
|
44
|
+
*/
|
|
45
|
+
onTogglePlay?: () => void;
|
|
46
|
+
/** Extra controls rendered before the trailing buttons */
|
|
47
|
+
extraControls?: Snippet;
|
|
48
|
+
/** Additional classes */
|
|
49
|
+
class?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let {
|
|
53
|
+
player,
|
|
54
|
+
variant = 'video',
|
|
55
|
+
showVolume = true,
|
|
56
|
+
showRate = true,
|
|
57
|
+
showCaptions = true,
|
|
58
|
+
showPictureInPicture = variant === 'video',
|
|
59
|
+
showFullscreen = variant === 'video',
|
|
60
|
+
fullscreenTarget = null,
|
|
61
|
+
onTogglePlay,
|
|
62
|
+
extraControls,
|
|
63
|
+
class: className,
|
|
64
|
+
}: Props = $props();
|
|
65
|
+
|
|
66
|
+
// `currentColor` cannot take an opacity modifier, so hover and track shading
|
|
67
|
+
// are resolved per variant instead of inherited from the text colour.
|
|
68
|
+
const isLight = $derived(variant === 'video');
|
|
69
|
+
|
|
70
|
+
const buttonClass = $derived(
|
|
71
|
+
cn(
|
|
72
|
+
'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
73
|
+
isLight
|
|
74
|
+
? 'hover:bg-white/20 focus-visible:ring-white focus-visible:ring-offset-transparent'
|
|
75
|
+
: 'hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring focus-visible:ring-offset-background'
|
|
76
|
+
)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const volumeTrackClass = $derived(isLight ? 'bg-white/30' : 'bg-foreground/20');
|
|
80
|
+
const volumeFillClass = $derived(isLight ? 'bg-white' : 'bg-primary');
|
|
81
|
+
|
|
82
|
+
const hasCaptions = $derived(player.textTracks.length > 0);
|
|
83
|
+
const captionsOn = $derived(player.activeTextTrack !== null);
|
|
84
|
+
const volumeValue = $derived(player.muted ? 0 : Math.round(player.volume * 100));
|
|
85
|
+
|
|
86
|
+
const volumeIcon = $derived(
|
|
87
|
+
player.muted || player.volume === 0 ? ('volume-x' as const) : ('volume' as const)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
function cycleRate() {
|
|
91
|
+
const currentIndex = PLAYBACK_RATES.indexOf(
|
|
92
|
+
player.playbackRate as (typeof PLAYBACK_RATES)[number]
|
|
93
|
+
);
|
|
94
|
+
const next = PLAYBACK_RATES[(currentIndex + 1) % PLAYBACK_RATES.length];
|
|
95
|
+
player.setPlaybackRate(next);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* True only while the viewer is actually dragging or keying the volume thumb.
|
|
100
|
+
*
|
|
101
|
+
* The slider is controlled by the player's volume, and Bits UI emits
|
|
102
|
+
* `onValueChange` for programmatic updates as well as real input. Acting on
|
|
103
|
+
* every change would echo the player's own state back into it — muting, for
|
|
104
|
+
* instance, pushes 0 into the slider, which would then set the real volume
|
|
105
|
+
* to 0 and leave unmuting silent.
|
|
106
|
+
*/
|
|
107
|
+
let adjustingVolume = $state(false);
|
|
108
|
+
|
|
109
|
+
function beginVolumeAdjust() {
|
|
110
|
+
adjustingVolume = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function endVolumeAdjust() {
|
|
114
|
+
adjustingVolume = false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const VOLUME_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
|
|
118
|
+
|
|
119
|
+
function handleVolumeKeyDown(event: KeyboardEvent) {
|
|
120
|
+
if (VOLUME_KEYS.includes(event.key)) beginVolumeAdjust();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
$effect(() => {
|
|
124
|
+
if (!adjustingVolume) return;
|
|
125
|
+
window.addEventListener('pointerup', endVolumeAdjust);
|
|
126
|
+
window.addEventListener('pointercancel', endVolumeAdjust);
|
|
127
|
+
window.addEventListener('keyup', endVolumeAdjust);
|
|
128
|
+
return () => {
|
|
129
|
+
window.removeEventListener('pointerup', endVolumeAdjust);
|
|
130
|
+
window.removeEventListener('pointercancel', endVolumeAdjust);
|
|
131
|
+
window.removeEventListener('keyup', endVolumeAdjust);
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function handleVolumeChange(next: number) {
|
|
136
|
+
if (!adjustingVolume) return;
|
|
137
|
+
player.setVolume(next / 100);
|
|
138
|
+
}
|
|
139
|
+
</script>
|
|
140
|
+
|
|
141
|
+
<div
|
|
142
|
+
class={cn(
|
|
143
|
+
'flex w-full flex-col gap-1 px-3 py-2 text-sm',
|
|
144
|
+
variant === 'video'
|
|
145
|
+
? 'bg-gradient-to-t from-black/80 to-transparent pt-8 text-white'
|
|
146
|
+
: 'text-foreground',
|
|
147
|
+
className
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
<MediaScrubber
|
|
151
|
+
currentTime={player.currentTime}
|
|
152
|
+
duration={player.duration}
|
|
153
|
+
buffered={player.buffered}
|
|
154
|
+
onSeek={(seconds) => player.seek(seconds)}
|
|
155
|
+
label="Seek"
|
|
156
|
+
tone={isLight ? 'light' : 'default'}
|
|
157
|
+
/>
|
|
158
|
+
|
|
159
|
+
<div class="flex items-center gap-1">
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
class={buttonClass}
|
|
163
|
+
onclick={() => (onTogglePlay ? onTogglePlay() : player.toggle())}
|
|
164
|
+
aria-label={player.playing ? 'Pause' : 'Play'}
|
|
165
|
+
>
|
|
166
|
+
<Icon name={player.playing ? 'pause' : 'play'} size={20} />
|
|
167
|
+
</button>
|
|
168
|
+
|
|
169
|
+
<span class="ml-1 shrink-0 select-none text-xs tabular-nums" aria-hidden="true">
|
|
170
|
+
{formatDuration(player.currentTime)} / {formatDuration(player.duration)}
|
|
171
|
+
</span>
|
|
172
|
+
|
|
173
|
+
<div class="flex-1"></div>
|
|
174
|
+
|
|
175
|
+
{#if extraControls}
|
|
176
|
+
{@render extraControls()}
|
|
177
|
+
{/if}
|
|
178
|
+
|
|
179
|
+
{#if showVolume}
|
|
180
|
+
<!-- Grouped so the slider expands on hover and stays open while focused. -->
|
|
181
|
+
<div class="group/volume flex items-center">
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
class={buttonClass}
|
|
185
|
+
onclick={() => player.toggleMute()}
|
|
186
|
+
aria-label={player.muted ? 'Unmute' : 'Mute'}
|
|
187
|
+
>
|
|
188
|
+
<Icon name={volumeIcon} size={20} />
|
|
189
|
+
</button>
|
|
190
|
+
|
|
191
|
+
<!--
|
|
192
|
+
Capture-phase handlers sit here rather than on the slider: Bits UI
|
|
193
|
+
acts on the input before a bubbling listener would run, and its
|
|
194
|
+
`mergeProps` does not forward `on*capture` props to the DOM. This
|
|
195
|
+
wrapper holds only the slider, so the mute button beside it cannot
|
|
196
|
+
open the gate and let a mute echo zero the volume.
|
|
197
|
+
-->
|
|
198
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
199
|
+
<div
|
|
200
|
+
class="w-0 overflow-hidden opacity-0 transition-all duration-200 group-focus-within/volume:w-20 group-focus-within/volume:opacity-100 group-hover/volume:w-20 group-hover/volume:opacity-100"
|
|
201
|
+
onpointerdowncapture={beginVolumeAdjust}
|
|
202
|
+
onkeydowncapture={handleVolumeKeyDown}
|
|
203
|
+
>
|
|
204
|
+
<SliderPrimitive.Root
|
|
205
|
+
type="single"
|
|
206
|
+
value={volumeValue}
|
|
207
|
+
min={0}
|
|
208
|
+
max={100}
|
|
209
|
+
step={5}
|
|
210
|
+
onValueChange={handleVolumeChange}
|
|
211
|
+
class={cn(
|
|
212
|
+
'relative mx-2 flex h-1.5 w-16 touch-none select-none items-center rounded-full',
|
|
213
|
+
volumeTrackClass
|
|
214
|
+
)}
|
|
215
|
+
>
|
|
216
|
+
<SliderPrimitive.Range class={cn('absolute h-full rounded-full', volumeFillClass)} />
|
|
217
|
+
<SliderPrimitive.Thumb
|
|
218
|
+
index={0}
|
|
219
|
+
aria-label="Volume"
|
|
220
|
+
aria-valuetext="{volumeValue} percent"
|
|
221
|
+
class={cn(
|
|
222
|
+
'block h-3 w-3 rounded-full shadow outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
|
223
|
+
volumeFillClass,
|
|
224
|
+
isLight
|
|
225
|
+
? 'focus-visible:ring-white focus-visible:ring-offset-transparent'
|
|
226
|
+
: 'focus-visible:ring-ring focus-visible:ring-offset-background'
|
|
227
|
+
)}
|
|
228
|
+
/>
|
|
229
|
+
</SliderPrimitive.Root>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
{/if}
|
|
233
|
+
|
|
234
|
+
{#if showRate}
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
class={cn(buttonClass, 'w-auto px-2 text-xs font-semibold tabular-nums')}
|
|
238
|
+
onclick={cycleRate}
|
|
239
|
+
aria-label="Playback speed, currently {player.playbackRate}×. Activate to change."
|
|
240
|
+
>
|
|
241
|
+
{player.playbackRate}×
|
|
242
|
+
</button>
|
|
243
|
+
{/if}
|
|
244
|
+
|
|
245
|
+
{#if showCaptions && hasCaptions}
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
class={cn(
|
|
249
|
+
buttonClass,
|
|
250
|
+
'w-auto px-2 text-xs font-semibold',
|
|
251
|
+
captionsOn && (isLight ? 'bg-white/25' : 'bg-accent text-accent-foreground')
|
|
252
|
+
)}
|
|
253
|
+
onclick={() => player.toggleCaptions()}
|
|
254
|
+
aria-pressed={captionsOn}
|
|
255
|
+
aria-label="Captions"
|
|
256
|
+
>
|
|
257
|
+
CC
|
|
258
|
+
</button>
|
|
259
|
+
{/if}
|
|
260
|
+
|
|
261
|
+
{#if showPictureInPicture}
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
class={buttonClass}
|
|
265
|
+
onclick={() => player.togglePictureInPicture()}
|
|
266
|
+
aria-label={player.pip ? 'Exit picture in picture' : 'Picture in picture'}
|
|
267
|
+
>
|
|
268
|
+
<Icon name="minimize" size={18} />
|
|
269
|
+
</button>
|
|
270
|
+
{/if}
|
|
271
|
+
|
|
272
|
+
{#if showFullscreen}
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
class={buttonClass}
|
|
276
|
+
onclick={() => player.toggleFullscreen(fullscreenTarget)}
|
|
277
|
+
aria-label={player.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
|
278
|
+
>
|
|
279
|
+
<Icon name={player.fullscreen ? 'minimize' : 'maximize'} size={20} />
|
|
280
|
+
</button>
|
|
281
|
+
{/if}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { MediaPlayerApi } from '../../../composables/useMediaPlayer.svelte.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Player API driving the controls */
|
|
5
|
+
player: MediaPlayerApi;
|
|
6
|
+
/** `video` overlays a translucent bar; `audio` renders a solid inline bar */
|
|
7
|
+
variant?: 'video' | 'audio';
|
|
8
|
+
/** Show the mute button and volume slider */
|
|
9
|
+
showVolume?: boolean;
|
|
10
|
+
/** Show the playback-speed button */
|
|
11
|
+
showRate?: boolean;
|
|
12
|
+
/** Show the captions button (also requires at least one text track) */
|
|
13
|
+
showCaptions?: boolean;
|
|
14
|
+
/** Show the picture-in-picture button (video only, where supported) */
|
|
15
|
+
showPictureInPicture?: boolean;
|
|
16
|
+
/** Show the fullscreen button (video only) */
|
|
17
|
+
showFullscreen?: boolean;
|
|
18
|
+
/** Element made fullscreen — normally the player container, not the media element */
|
|
19
|
+
fullscreenTarget?: HTMLElement | null;
|
|
20
|
+
/**
|
|
21
|
+
* Override for the play/pause button, so the player can do extra work on
|
|
22
|
+
* play — releasing a held-back stream loader, for instance.
|
|
23
|
+
*/
|
|
24
|
+
onTogglePlay?: () => void;
|
|
25
|
+
/** Extra controls rendered before the trailing buttons */
|
|
26
|
+
extraControls?: Snippet;
|
|
27
|
+
/** Additional classes */
|
|
28
|
+
class?: string;
|
|
29
|
+
}
|
|
30
|
+
declare const MediaControls: import("svelte").Component<Props, {}, "">;
|
|
31
|
+
type MediaControls = ReturnType<typeof MediaControls>;
|
|
32
|
+
export default MediaControls;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* MediaScrubber - Seek bar for the Video and Audio players (internal)
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Buffered ranges rendered behind the played range
|
|
7
|
+
* - Hover and focus preview showing the timestamp under the pointer
|
|
8
|
+
* - Keyboard seeking inherited from the Bits UI slider primitive
|
|
9
|
+
* - `aria-valuetext` announces spoken timestamps, never raw seconds
|
|
10
|
+
*
|
|
11
|
+
* Not exported from the package — use `Video` or `Audio` instead.
|
|
12
|
+
*/
|
|
13
|
+
import { Slider as SliderPrimitive } from 'bits-ui';
|
|
14
|
+
import type { MediaTimeRange } from '../../../composables/useMediaPlayer.svelte.js';
|
|
15
|
+
import { cn } from '../../../utils.js';
|
|
16
|
+
import { clamp, formatDuration, formatDurationSpoken } from '../../../utils/media.js';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
/** Current playhead position in seconds */
|
|
20
|
+
currentTime: number;
|
|
21
|
+
/** Total duration in seconds; `0` while unknown */
|
|
22
|
+
duration: number;
|
|
23
|
+
/** Buffered ranges, drawn behind the played range */
|
|
24
|
+
buffered?: MediaTimeRange[];
|
|
25
|
+
/** Called continuously while dragging */
|
|
26
|
+
onSeek: (seconds: number) => void;
|
|
27
|
+
/** Called once when the drag is released */
|
|
28
|
+
onSeekCommit?: (seconds: number) => void;
|
|
29
|
+
/** Disable interaction (no duration yet, or an errored source) */
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** Accessible name for the seek bar */
|
|
32
|
+
label?: string;
|
|
33
|
+
/**
|
|
34
|
+
* `light` renders for a dark backdrop (the video overlay); `default`
|
|
35
|
+
* renders against the page background (the audio bar).
|
|
36
|
+
*/
|
|
37
|
+
tone?: 'default' | 'light';
|
|
38
|
+
/** Additional classes */
|
|
39
|
+
class?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let {
|
|
43
|
+
currentTime,
|
|
44
|
+
duration,
|
|
45
|
+
buffered = [],
|
|
46
|
+
onSeek,
|
|
47
|
+
onSeekCommit,
|
|
48
|
+
disabled = false,
|
|
49
|
+
label = 'Seek',
|
|
50
|
+
tone = 'default',
|
|
51
|
+
class: className,
|
|
52
|
+
}: Props = $props();
|
|
53
|
+
|
|
54
|
+
// `currentColor` cannot carry an opacity modifier, so the neutral track and
|
|
55
|
+
// buffered shading are resolved per tone rather than inherited from text.
|
|
56
|
+
const trackClass = $derived(tone === 'light' ? 'bg-white/30' : 'bg-foreground/20');
|
|
57
|
+
const bufferedClass = $derived(tone === 'light' ? 'bg-white/40' : 'bg-foreground/30');
|
|
58
|
+
const tooltipClass = $derived(
|
|
59
|
+
tone === 'light' ? 'bg-white text-neutral-900' : 'bg-foreground text-background'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
let trackElement = $state<HTMLElement | null>(null);
|
|
63
|
+
let hoverTime = $state<number | null>(null);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* True only while the viewer is actually dragging or keying the thumb.
|
|
67
|
+
*
|
|
68
|
+
* The slider is controlled: its value follows the playhead. Bits UI emits
|
|
69
|
+
* `onValueChange` for those programmatic updates as well as for real input,
|
|
70
|
+
* so seeking on every change would feed the playhead straight back in as a
|
|
71
|
+
* seek — pinning playback at the first step and starving the element into a
|
|
72
|
+
* permanent buffering loop. Only a genuine interaction may seek.
|
|
73
|
+
*/
|
|
74
|
+
let scrubbing = $state(false);
|
|
75
|
+
|
|
76
|
+
// Bound in the capture phase on the wrapper, not on the slider itself: Bits
|
|
77
|
+
// UI acts on the key before a bubbling listener would run, and its
|
|
78
|
+
// `mergeProps` does not forward `on*capture` props through to the DOM.
|
|
79
|
+
function beginScrub() {
|
|
80
|
+
if (!interactive) return;
|
|
81
|
+
scrubbing = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function endScrub() {
|
|
85
|
+
scrubbing = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Keys Bits UI's slider acts on; anything else must not start a scrub. */
|
|
89
|
+
const SCRUB_KEYS = [
|
|
90
|
+
'ArrowLeft',
|
|
91
|
+
'ArrowRight',
|
|
92
|
+
'ArrowUp',
|
|
93
|
+
'ArrowDown',
|
|
94
|
+
'Home',
|
|
95
|
+
'End',
|
|
96
|
+
'PageUp',
|
|
97
|
+
'PageDown',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
101
|
+
if (SCRUB_KEYS.includes(event.key)) beginScrub();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A drag very often ends outside the slider, so the release is watched on
|
|
105
|
+
// the window rather than the element.
|
|
106
|
+
$effect(() => {
|
|
107
|
+
if (!scrubbing) return;
|
|
108
|
+
window.addEventListener('pointerup', endScrub);
|
|
109
|
+
window.addEventListener('pointercancel', endScrub);
|
|
110
|
+
window.addEventListener('keyup', endScrub);
|
|
111
|
+
return () => {
|
|
112
|
+
window.removeEventListener('pointerup', endScrub);
|
|
113
|
+
window.removeEventListener('pointercancel', endScrub);
|
|
114
|
+
window.removeEventListener('keyup', endScrub);
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function handleValueChange(next: number) {
|
|
119
|
+
if (!scrubbing) return;
|
|
120
|
+
onSeek(next);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleValueCommit(next: number) {
|
|
124
|
+
if (!scrubbing) return;
|
|
125
|
+
onSeekCommit?.(next);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A zero max makes the slider maths collapse, so hold it at 1 until the
|
|
129
|
+
// real duration arrives and disable interaction in the meantime.
|
|
130
|
+
const max = $derived(duration > 0 ? duration : 1);
|
|
131
|
+
const interactive = $derived(!disabled && duration > 0);
|
|
132
|
+
const value = $derived(clamp(currentTime, 0, max));
|
|
133
|
+
|
|
134
|
+
const valueText = $derived(
|
|
135
|
+
`${formatDurationSpoken(currentTime)} of ${formatDurationSpoken(duration)}`
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const hoverPercent = $derived(hoverTime === null ? 0 : (hoverTime / max) * 100);
|
|
139
|
+
|
|
140
|
+
function handlePointerMove(event: PointerEvent) {
|
|
141
|
+
if (!trackElement || !interactive) return;
|
|
142
|
+
const rect = trackElement.getBoundingClientRect();
|
|
143
|
+
if (rect.width === 0) return;
|
|
144
|
+
hoverTime = clamp((event.clientX - rect.left) / rect.width, 0, 1) * duration;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function handlePointerLeave() {
|
|
148
|
+
hoverTime = null;
|
|
149
|
+
}
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<!--
|
|
153
|
+
The pointer handlers only drive the decorative hover timestamp; all real
|
|
154
|
+
interaction lives on the slider thumb inside, which carries the ARIA.
|
|
155
|
+
-->
|
|
156
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
157
|
+
<div
|
|
158
|
+
class={cn('group/scrubber relative flex w-full items-center', className)}
|
|
159
|
+
bind:this={trackElement}
|
|
160
|
+
onpointermove={handlePointerMove}
|
|
161
|
+
onpointerleave={handlePointerLeave}
|
|
162
|
+
onpointerdowncapture={beginScrub}
|
|
163
|
+
onkeydowncapture={handleKeyDown}
|
|
164
|
+
>
|
|
165
|
+
{#if hoverTime !== null}
|
|
166
|
+
<!-- Timestamp preview. Decorative: the value is already announced via aria-valuetext. -->
|
|
167
|
+
<div
|
|
168
|
+
class={cn(
|
|
169
|
+
'pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded px-1.5 py-0.5 text-xs font-medium tabular-nums shadow-sm',
|
|
170
|
+
tooltipClass
|
|
171
|
+
)}
|
|
172
|
+
style="left: {hoverPercent}%"
|
|
173
|
+
aria-hidden="true"
|
|
174
|
+
>
|
|
175
|
+
{formatDuration(hoverTime)}
|
|
176
|
+
</div>
|
|
177
|
+
{/if}
|
|
178
|
+
|
|
179
|
+
<SliderPrimitive.Root
|
|
180
|
+
type="single"
|
|
181
|
+
{value}
|
|
182
|
+
min={0}
|
|
183
|
+
{max}
|
|
184
|
+
step={0.1}
|
|
185
|
+
disabled={!interactive}
|
|
186
|
+
onValueChange={handleValueChange}
|
|
187
|
+
onValueCommit={handleValueCommit}
|
|
188
|
+
class={cn(
|
|
189
|
+
'relative flex h-1.5 w-full touch-none select-none items-center rounded-full transition-[height] group-hover/scrubber:h-2.5 data-[disabled]:opacity-50',
|
|
190
|
+
trackClass
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
{#each buffered as range (range.start)}
|
|
194
|
+
<span
|
|
195
|
+
class={cn('absolute h-full rounded-full', bufferedClass)}
|
|
196
|
+
style="left: {(range.start / max) * 100}%; width: {((range.end - range.start) / max) *
|
|
197
|
+
100}%"
|
|
198
|
+
aria-hidden="true"
|
|
199
|
+
></span>
|
|
200
|
+
{/each}
|
|
201
|
+
|
|
202
|
+
<SliderPrimitive.Range class="absolute h-full rounded-full bg-primary" />
|
|
203
|
+
|
|
204
|
+
<SliderPrimitive.Thumb
|
|
205
|
+
index={0}
|
|
206
|
+
aria-label={label}
|
|
207
|
+
aria-valuetext={valueText}
|
|
208
|
+
class="block h-3.5 w-3.5 rounded-full bg-primary opacity-0 shadow ring-offset-background transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 group-hover/scrubber:opacity-100 data-[disabled]:pointer-events-none"
|
|
209
|
+
/>
|
|
210
|
+
</SliderPrimitive.Root>
|
|
211
|
+
</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MediaTimeRange } from '../../../composables/useMediaPlayer.svelte.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Current playhead position in seconds */
|
|
4
|
+
currentTime: number;
|
|
5
|
+
/** Total duration in seconds; `0` while unknown */
|
|
6
|
+
duration: number;
|
|
7
|
+
/** Buffered ranges, drawn behind the played range */
|
|
8
|
+
buffered?: MediaTimeRange[];
|
|
9
|
+
/** Called continuously while dragging */
|
|
10
|
+
onSeek: (seconds: number) => void;
|
|
11
|
+
/** Called once when the drag is released */
|
|
12
|
+
onSeekCommit?: (seconds: number) => void;
|
|
13
|
+
/** Disable interaction (no duration yet, or an errored source) */
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
/** Accessible name for the seek bar */
|
|
16
|
+
label?: string;
|
|
17
|
+
/**
|
|
18
|
+
* `light` renders for a dark backdrop (the video overlay); `default`
|
|
19
|
+
* renders against the page background (the audio bar).
|
|
20
|
+
*/
|
|
21
|
+
tone?: 'default' | 'light';
|
|
22
|
+
/** Additional classes */
|
|
23
|
+
class?: string;
|
|
24
|
+
}
|
|
25
|
+
declare const MediaScrubber: import("svelte").Component<Props, {}, "">;
|
|
26
|
+
type MediaScrubber = ReturnType<typeof MediaScrubber>;
|
|
27
|
+
export default MediaScrubber;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* MediaTile - Thumbnail button used by MediaGrid, MediaCarousel, and the
|
|
4
|
+
* lightbox thumbnail strip (internal)
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Renders a real `<button>` with an accessible name derived from the item
|
|
8
|
+
* - Play affordance and duration badge for time-based media
|
|
9
|
+
* - Icon placeholder when the item has no thumbnail (audio without artwork)
|
|
10
|
+
*
|
|
11
|
+
* Not exported from the package.
|
|
12
|
+
*/
|
|
13
|
+
import type { IconName } from '../../Icon.svelte';
|
|
14
|
+
import type { MediaAspectRatio, MediaItem } from '../../../types/components.js';
|
|
15
|
+
import { cn } from '../../../utils.js';
|
|
16
|
+
import {
|
|
17
|
+
formatDuration,
|
|
18
|
+
getMediaLabel,
|
|
19
|
+
getMediaThumbnail,
|
|
20
|
+
isSpatialMedia,
|
|
21
|
+
isTimeBasedMedia,
|
|
22
|
+
resolveAspectRatio,
|
|
23
|
+
} from '../../../utils/media.js';
|
|
24
|
+
import Icon from '../../Icon.svelte';
|
|
25
|
+
import Image from '../Image.svelte';
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
/** Item to represent */
|
|
29
|
+
item: MediaItem;
|
|
30
|
+
/** Zero-based position, used for the fallback accessible name */
|
|
31
|
+
index: number;
|
|
32
|
+
/** Aspect ratio of the tile. Omit to let the thumbnail set its own height. */
|
|
33
|
+
ratio?: MediaAspectRatio | number;
|
|
34
|
+
/** Marks the tile as the current item (carousel dots, thumbnail strips) */
|
|
35
|
+
selected?: boolean;
|
|
36
|
+
/** Participates in a roving tabindex container */
|
|
37
|
+
roving?: boolean;
|
|
38
|
+
/** Click and keyboard activation handler */
|
|
39
|
+
onselect?: (index: number) => void;
|
|
40
|
+
/** Additional classes */
|
|
41
|
+
class?: string;
|
|
42
|
+
/** Any other attribute is forwarded to the button (tabindex, data-*, …) */
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let {
|
|
47
|
+
item,
|
|
48
|
+
index,
|
|
49
|
+
ratio,
|
|
50
|
+
selected = false,
|
|
51
|
+
roving = false,
|
|
52
|
+
onselect,
|
|
53
|
+
class: className,
|
|
54
|
+
...restProps
|
|
55
|
+
}: Props = $props();
|
|
56
|
+
|
|
57
|
+
const thumbnail = $derived(getMediaThumbnail(item));
|
|
58
|
+
const label = $derived(getMediaLabel(item, index));
|
|
59
|
+
const timeBased = $derived(isTimeBasedMedia(item));
|
|
60
|
+
/** A 3D tour opens rather than plays, so it gets its own affordance. */
|
|
61
|
+
const spatial = $derived(isSpatialMedia(item));
|
|
62
|
+
|
|
63
|
+
const duration = $derived(
|
|
64
|
+
item.kind === 'video' || item.kind === 'audio' ? item.duration : undefined
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const placeholderIcon = $derived<IconName>(
|
|
68
|
+
spatial ? 'box' : item.kind === 'audio' ? 'music' : item.kind === 'image' ? 'image' : 'video'
|
|
69
|
+
);
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
data-roving-tabindex-item={roving ? '' : undefined}
|
|
75
|
+
aria-current={selected ? 'true' : undefined}
|
|
76
|
+
class={cn(
|
|
77
|
+
'group/tile relative block w-full overflow-hidden rounded-md bg-muted outline-none',
|
|
78
|
+
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
|
79
|
+
selected && 'ring-2 ring-primary ring-offset-2 ring-offset-background',
|
|
80
|
+
className
|
|
81
|
+
)}
|
|
82
|
+
onclick={() => onselect?.(index)}
|
|
83
|
+
{...restProps}
|
|
84
|
+
>
|
|
85
|
+
{#if thumbnail}
|
|
86
|
+
<Image
|
|
87
|
+
src={thumbnail}
|
|
88
|
+
alt=""
|
|
89
|
+
{ratio}
|
|
90
|
+
fit="cover"
|
|
91
|
+
class="h-full w-full rounded-none"
|
|
92
|
+
showSkeleton
|
|
93
|
+
/>
|
|
94
|
+
{:else}
|
|
95
|
+
<div
|
|
96
|
+
class={cn(
|
|
97
|
+
'flex w-full items-center justify-center bg-muted text-muted-foreground',
|
|
98
|
+
ratio === undefined && 'aspect-square'
|
|
99
|
+
)}
|
|
100
|
+
style={ratio === undefined ? undefined : `aspect-ratio: ${resolveAspectRatio(ratio)}`}
|
|
101
|
+
>
|
|
102
|
+
<Icon name={placeholderIcon} size={28} />
|
|
103
|
+
</div>
|
|
104
|
+
{/if}
|
|
105
|
+
|
|
106
|
+
{#if timeBased || spatial}
|
|
107
|
+
<span
|
|
108
|
+
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/10 opacity-0 transition-opacity group-hover/tile:opacity-100 group-focus-visible/tile:opacity-100"
|
|
109
|
+
aria-hidden="true"
|
|
110
|
+
>
|
|
111
|
+
<span
|
|
112
|
+
class="flex h-10 w-10 items-center justify-center rounded-full bg-black/65 text-white shadow"
|
|
113
|
+
>
|
|
114
|
+
<Icon name={spatial ? 'box' : 'play'} size={20} />
|
|
115
|
+
</span>
|
|
116
|
+
</span>
|
|
117
|
+
{/if}
|
|
118
|
+
|
|
119
|
+
{#if duration !== undefined}
|
|
120
|
+
<span
|
|
121
|
+
class="pointer-events-none absolute bottom-1 right-1 rounded bg-black/75 px-1.5 py-0.5 text-[0.6875rem] font-medium tabular-nums text-white"
|
|
122
|
+
aria-hidden="true"
|
|
123
|
+
>
|
|
124
|
+
{formatDuration(duration)}
|
|
125
|
+
</span>
|
|
126
|
+
{/if}
|
|
127
|
+
|
|
128
|
+
<span class="sr-only">{label}</span>
|
|
129
|
+
</button>
|