@delightstack/components 0.1.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/LICENSE +21 -0
- package/README.md +136 -0
- package/SKILL.md +149 -0
- package/bin/agents.js +63 -0
- package/dist/actions/Alert.svelte +202 -0
- package/dist/actions/Alert.svelte.d.ts +36 -0
- package/dist/actions/Alert.svelte.d.ts.map +1 -0
- package/dist/actions/Button.svelte +1450 -0
- package/dist/actions/Button.svelte.d.ts +56 -0
- package/dist/actions/Button.svelte.d.ts.map +1 -0
- package/dist/actions/ButtonGroup.svelte +111 -0
- package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
- package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
- package/dist/actions/CommandPalette.svelte +939 -0
- package/dist/actions/CommandPalette.svelte.d.ts +37 -0
- package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
- package/dist/actions/ContextMenu.svelte +138 -0
- package/dist/actions/ContextMenu.svelte.d.ts +54 -0
- package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
- package/dist/actions/Modal.svelte +474 -0
- package/dist/actions/Modal.svelte.d.ts +28 -0
- package/dist/actions/Modal.svelte.d.ts.map +1 -0
- package/dist/actions/Popover.svelte +1214 -0
- package/dist/actions/Popover.svelte.d.ts +31 -0
- package/dist/actions/Popover.svelte.d.ts.map +1 -0
- package/dist/actions/Portal.svelte +80 -0
- package/dist/actions/Portal.svelte.d.ts +17 -0
- package/dist/actions/Portal.svelte.d.ts.map +1 -0
- package/dist/actions/ThemeToggle.svelte +345 -0
- package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
- package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
- package/dist/actions/index.d.ts +13 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +10 -0
- package/dist/actions/scrollbar.d.ts +48 -0
- package/dist/actions/scrollbar.d.ts.map +1 -0
- package/dist/actions/scrollbar.js +404 -0
- package/dist/display/Accordion.svelte +586 -0
- package/dist/display/Accordion.svelte.d.ts +41 -0
- package/dist/display/Accordion.svelte.d.ts.map +1 -0
- package/dist/display/Avatar.svelte +527 -0
- package/dist/display/Avatar.svelte.d.ts +22 -0
- package/dist/display/Avatar.svelte.d.ts.map +1 -0
- package/dist/display/AvatarGroup.svelte +298 -0
- package/dist/display/AvatarGroup.svelte.d.ts +31 -0
- package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
- package/dist/display/Calendar.svelte +1366 -0
- package/dist/display/Calendar.svelte.d.ts +58 -0
- package/dist/display/Calendar.svelte.d.ts.map +1 -0
- package/dist/display/Chart.svelte +1426 -0
- package/dist/display/Chart.svelte.d.ts +35 -0
- package/dist/display/Chart.svelte.d.ts.map +1 -0
- package/dist/display/Code.svelte +780 -0
- package/dist/display/Code.svelte.d.ts +19 -0
- package/dist/display/Code.svelte.d.ts.map +1 -0
- package/dist/display/Comparison.svelte +686 -0
- package/dist/display/Comparison.svelte.d.ts +22 -0
- package/dist/display/Comparison.svelte.d.ts.map +1 -0
- package/dist/display/Counter.svelte +285 -0
- package/dist/display/Counter.svelte.d.ts +21 -0
- package/dist/display/Counter.svelte.d.ts.map +1 -0
- package/dist/display/Expand.svelte +48 -0
- package/dist/display/Expand.svelte.d.ts +9 -0
- package/dist/display/Expand.svelte.d.ts.map +1 -0
- package/dist/display/List.svelte +294 -0
- package/dist/display/List.svelte.d.ts +40 -0
- package/dist/display/List.svelte.d.ts.map +1 -0
- package/dist/display/ListContextReset.svelte +19 -0
- package/dist/display/ListContextReset.svelte.d.ts +7 -0
- package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
- package/dist/display/ListItem.svelte +834 -0
- package/dist/display/ListItem.svelte.d.ts +22 -0
- package/dist/display/ListItem.svelte.d.ts.map +1 -0
- package/dist/display/QR.svelte +1193 -0
- package/dist/display/QR.svelte.d.ts +23 -0
- package/dist/display/QR.svelte.d.ts.map +1 -0
- package/dist/display/SplitPane.svelte +744 -0
- package/dist/display/SplitPane.svelte.d.ts +25 -0
- package/dist/display/SplitPane.svelte.d.ts.map +1 -0
- package/dist/display/Stat.svelte +439 -0
- package/dist/display/Stat.svelte.d.ts +24 -0
- package/dist/display/Stat.svelte.d.ts.map +1 -0
- package/dist/display/Table.svelte +4654 -0
- package/dist/display/Table.svelte.d.ts +249 -0
- package/dist/display/Table.svelte.d.ts.map +1 -0
- package/dist/display/TableCellEditor.svelte +935 -0
- package/dist/display/TableCellEditor.svelte.d.ts +58 -0
- package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
- package/dist/display/Timeline.svelte +1258 -0
- package/dist/display/Timeline.svelte.d.ts +43 -0
- package/dist/display/Timeline.svelte.d.ts.map +1 -0
- package/dist/display/Tree.svelte +1740 -0
- package/dist/display/Tree.svelte.d.ts +74 -0
- package/dist/display/Tree.svelte.d.ts.map +1 -0
- package/dist/display/Typewriter.svelte +338 -0
- package/dist/display/Typewriter.svelte.d.ts +22 -0
- package/dist/display/Typewriter.svelte.d.ts.map +1 -0
- package/dist/display/index.d.ts +24 -0
- package/dist/display/index.d.ts.map +1 -0
- package/dist/display/index.js +18 -0
- package/dist/feedback/Callout.svelte +529 -0
- package/dist/feedback/Callout.svelte.d.ts +24 -0
- package/dist/feedback/Callout.svelte.d.ts.map +1 -0
- package/dist/feedback/Confetti.svelte +631 -0
- package/dist/feedback/Confetti.svelte.d.ts +90 -0
- package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
- package/dist/feedback/Progress.svelte +382 -0
- package/dist/feedback/Progress.svelte.d.ts +25 -0
- package/dist/feedback/Progress.svelte.d.ts.map +1 -0
- package/dist/feedback/Toast.svelte +967 -0
- package/dist/feedback/Toast.svelte.d.ts +54 -0
- package/dist/feedback/Toast.svelte.d.ts.map +1 -0
- package/dist/feedback/index.d.ts +7 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/index.js +4 -0
- package/dist/form/Checkbox.svelte +449 -0
- package/dist/form/Checkbox.svelte.d.ts +27 -0
- package/dist/form/Checkbox.svelte.d.ts.map +1 -0
- package/dist/form/Fieldset.svelte +410 -0
- package/dist/form/Fieldset.svelte.d.ts +22 -0
- package/dist/form/Fieldset.svelte.d.ts.map +1 -0
- package/dist/form/FileUpload.svelte +934 -0
- package/dist/form/FileUpload.svelte.d.ts +41 -0
- package/dist/form/FileUpload.svelte.d.ts.map +1 -0
- package/dist/form/Form.svelte +530 -0
- package/dist/form/Form.svelte.d.ts +120 -0
- package/dist/form/Form.svelte.d.ts.map +1 -0
- package/dist/form/Input.svelte +2858 -0
- package/dist/form/Input.svelte.d.ts +66 -0
- package/dist/form/Input.svelte.d.ts.map +1 -0
- package/dist/form/Radio.svelte +507 -0
- package/dist/form/Radio.svelte.d.ts +39 -0
- package/dist/form/Radio.svelte.d.ts.map +1 -0
- package/dist/form/Range.svelte +912 -0
- package/dist/form/Range.svelte.d.ts +33 -0
- package/dist/form/Range.svelte.d.ts.map +1 -0
- package/dist/form/Rating.svelte +429 -0
- package/dist/form/Rating.svelte.d.ts +28 -0
- package/dist/form/Rating.svelte.d.ts.map +1 -0
- package/dist/form/Select.svelte +1933 -0
- package/dist/form/Select.svelte.d.ts +54 -0
- package/dist/form/Select.svelte.d.ts.map +1 -0
- package/dist/form/Toggle.svelte +645 -0
- package/dist/form/Toggle.svelte.d.ts +50 -0
- package/dist/form/Toggle.svelte.d.ts.map +1 -0
- package/dist/form/index.d.ts +15 -0
- package/dist/form/index.d.ts.map +1 -0
- package/dist/form/index.js +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/layout/README.md +172 -0
- package/dist/media/Carousel.svelte +2424 -0
- package/dist/media/Carousel.svelte.d.ts +47 -0
- package/dist/media/Carousel.svelte.d.ts.map +1 -0
- package/dist/media/Gallery.svelte +2881 -0
- package/dist/media/Gallery.svelte.d.ts +82 -0
- package/dist/media/Gallery.svelte.d.ts.map +1 -0
- package/dist/media/Image.svelte +389 -0
- package/dist/media/Image.svelte.d.ts +33 -0
- package/dist/media/Image.svelte.d.ts.map +1 -0
- package/dist/media/PDF.svelte +1793 -0
- package/dist/media/PDF.svelte.d.ts +44 -0
- package/dist/media/PDF.svelte.d.ts.map +1 -0
- package/dist/media/Panorama.svelte +1391 -0
- package/dist/media/Panorama.svelte.d.ts +47 -0
- package/dist/media/Panorama.svelte.d.ts.map +1 -0
- package/dist/media/Video.svelte +2501 -0
- package/dist/media/Video.svelte.d.ts +58 -0
- package/dist/media/Video.svelte.d.ts.map +1 -0
- package/dist/media/carousel.d.ts +211 -0
- package/dist/media/carousel.d.ts.map +1 -0
- package/dist/media/carousel.js +408 -0
- package/dist/media/index.d.ts +11 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +5 -0
- package/dist/navigation/BottomSheet.svelte +636 -0
- package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
- package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
- package/dist/navigation/Breadcrumbs.svelte +611 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
- package/dist/navigation/Pagination.svelte +641 -0
- package/dist/navigation/Pagination.svelte.d.ts +27 -0
- package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
- package/dist/navigation/Steps.svelte +965 -0
- package/dist/navigation/Steps.svelte.d.ts +43 -0
- package/dist/navigation/Steps.svelte.d.ts.map +1 -0
- package/dist/navigation/Tabs.svelte +698 -0
- package/dist/navigation/Tabs.svelte.d.ts +41 -0
- package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
- package/dist/navigation/index.d.ts +8 -0
- package/dist/navigation/index.d.ts.map +1 -0
- package/dist/navigation/index.js +5 -0
- package/package.json +139 -0
|
@@ -0,0 +1,2501 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface Source {
|
|
3
|
+
/** URL of the video source */
|
|
4
|
+
src: string;
|
|
5
|
+
/** MIME type of the source (e.g. `'video/mp4'`, `'application/x-mpegURL'`) */
|
|
6
|
+
type: string;
|
|
7
|
+
/** Vertical resolution of this source (e.g. 720, 1080) — used for the quality menu */
|
|
8
|
+
size?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Track {
|
|
12
|
+
/** The kind of text track */
|
|
13
|
+
kind: 'captions' | 'subtitles';
|
|
14
|
+
/** URL of the WebVTT track file */
|
|
15
|
+
src: string;
|
|
16
|
+
/** Language code of the track (e.g. `'en'`) */
|
|
17
|
+
srclang: string;
|
|
18
|
+
/** Display name of the track in the captions menu */
|
|
19
|
+
label: string;
|
|
20
|
+
/** Whether this track is enabled by default */
|
|
21
|
+
default?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// --- HLS helpers ---
|
|
25
|
+
const HLS_MIME = /(application\/(x-mpegurl|vnd\.apple\.mpegurl))/i;
|
|
26
|
+
|
|
27
|
+
/** True when a source MIME type identifies an HLS playlist. */
|
|
28
|
+
function isHlsType(type: string): boolean {
|
|
29
|
+
return HLS_MIME.test(type);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True when a URL points at an `.m3u8` playlist (ignoring query/hash). */
|
|
33
|
+
function isHlsUrl(url: string): boolean {
|
|
34
|
+
return /\.m3u8(?:[?#]|$)/i.test(url);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Best-guess MIME type for a bare string `src`. */
|
|
38
|
+
function inferType(url: string): string {
|
|
39
|
+
return isHlsUrl(url) ? 'application/vnd.apple.mpegurl' : 'video/mp4';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Memoized once per browser — Safari/iOS play HLS through the media element
|
|
43
|
+
// directly, so hls.js is never needed there. `undefined` until first checked.
|
|
44
|
+
let nativeHlsSupport: boolean | undefined;
|
|
45
|
+
|
|
46
|
+
/** Whether the platform can play HLS natively (Safari, iOS). */
|
|
47
|
+
function supportsNativeHls(): boolean {
|
|
48
|
+
if (nativeHlsSupport !== undefined) return nativeHlsSupport;
|
|
49
|
+
if (typeof document === 'undefined') return false; // SSR — re-checked on client
|
|
50
|
+
const probe = document.createElement('video');
|
|
51
|
+
nativeHlsSupport =
|
|
52
|
+
probe.canPlayType('application/vnd.apple.mpegurl') !== '' ||
|
|
53
|
+
probe.canPlayType('application/x-mpegurl') !== '';
|
|
54
|
+
return nativeHlsSupport;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Minimal structural types for the dynamically-imported hls.js, so we don't
|
|
58
|
+
// depend on the package's types being installed (it's an optional peer dep).
|
|
59
|
+
interface HlsLevelData {
|
|
60
|
+
height?: number;
|
|
61
|
+
bitrate?: number;
|
|
62
|
+
}
|
|
63
|
+
interface HlsErrorData {
|
|
64
|
+
fatal?: boolean;
|
|
65
|
+
type?: string;
|
|
66
|
+
}
|
|
67
|
+
interface HlsManifestData {
|
|
68
|
+
levels?: HlsLevelData[];
|
|
69
|
+
}
|
|
70
|
+
interface HlsInstance {
|
|
71
|
+
currentLevel: number;
|
|
72
|
+
loadSource(url: string): void;
|
|
73
|
+
attachMedia(media: HTMLMediaElement): void;
|
|
74
|
+
startLoad(): void;
|
|
75
|
+
recoverMediaError(): void;
|
|
76
|
+
destroy(): void;
|
|
77
|
+
on(
|
|
78
|
+
event: string,
|
|
79
|
+
cb: (event: string, data: HlsManifestData & HlsErrorData) => void,
|
|
80
|
+
): void;
|
|
81
|
+
}
|
|
82
|
+
interface HlsStatic {
|
|
83
|
+
new (config?: Record<string, unknown>): HlsInstance;
|
|
84
|
+
isSupported(): boolean;
|
|
85
|
+
Events: { MANIFEST_PARSED: string; ERROR: string };
|
|
86
|
+
ErrorTypes: { NETWORK_ERROR: string; MEDIA_ERROR: string };
|
|
87
|
+
}
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<script lang="ts">
|
|
91
|
+
import { ripple } from '@delightstack/utilities';
|
|
92
|
+
import { scale } from 'svelte/transition';
|
|
93
|
+
import { backOut } from 'svelte/easing';
|
|
94
|
+
import Range from '../form/Range.svelte';
|
|
95
|
+
const propId = $props.id();
|
|
96
|
+
|
|
97
|
+
let {
|
|
98
|
+
/** Video source URL or array of sources */
|
|
99
|
+
src,
|
|
100
|
+
|
|
101
|
+
/** Poster image URL */
|
|
102
|
+
poster = undefined as string | undefined,
|
|
103
|
+
|
|
104
|
+
/** Auto-play (requires muted for most browsers) */
|
|
105
|
+
autoplay = false,
|
|
106
|
+
|
|
107
|
+
/** Start muted */
|
|
108
|
+
muted = $bindable(false),
|
|
109
|
+
|
|
110
|
+
/** Loop playback */
|
|
111
|
+
loop = false,
|
|
112
|
+
|
|
113
|
+
/** Show custom controls */
|
|
114
|
+
controls = true,
|
|
115
|
+
|
|
116
|
+
/** CSS aspect ratio */
|
|
117
|
+
aspect_ratio = '16/9',
|
|
118
|
+
|
|
119
|
+
/** Preload behavior */
|
|
120
|
+
preload = 'metadata' as 'auto' | 'metadata' | 'none',
|
|
121
|
+
|
|
122
|
+
/** Caption/subtitle tracks */
|
|
123
|
+
captions = [] as Track[],
|
|
124
|
+
|
|
125
|
+
/** URL to a WebVTT thumbnail track. Each cue's text should be the URL
|
|
126
|
+
* of a sprite image, optionally suffixed with `#xywh=x,y,w,h` to point
|
|
127
|
+
* at a region of a sprite sheet. When provided, hovering the seek bar
|
|
128
|
+
* shows a thumbnail preview at the cue's mapped time. */
|
|
129
|
+
thumbnails = undefined as string | undefined,
|
|
130
|
+
|
|
131
|
+
/** Show loading skeleton (only while no `src` is known yet) */
|
|
132
|
+
skeleton = false,
|
|
133
|
+
|
|
134
|
+
/** Element ID */
|
|
135
|
+
id = propId,
|
|
136
|
+
|
|
137
|
+
/** Additional CSS classes */
|
|
138
|
+
class: class_name = '',
|
|
139
|
+
|
|
140
|
+
/** Bindable reference to the root HTML element */
|
|
141
|
+
element = $bindable(undefined as HTMLElement | undefined),
|
|
142
|
+
|
|
143
|
+
/** Bindable reference to the video element */
|
|
144
|
+
player = $bindable(undefined as HTMLVideoElement | undefined),
|
|
145
|
+
|
|
146
|
+
/** Playback started */
|
|
147
|
+
onplay = undefined as (() => void) | undefined,
|
|
148
|
+
|
|
149
|
+
/** Playback paused */
|
|
150
|
+
onpause = undefined as (() => void) | undefined,
|
|
151
|
+
|
|
152
|
+
/** Playback ended */
|
|
153
|
+
onended = undefined as (() => void) | undefined,
|
|
154
|
+
|
|
155
|
+
/** Time updated */
|
|
156
|
+
ontimeupdate = undefined as
|
|
157
|
+
| ((detail: { currentTime: number; duration: number }) => void)
|
|
158
|
+
| undefined,
|
|
159
|
+
|
|
160
|
+
/** Error occurred */
|
|
161
|
+
onerror = undefined as ((detail: { error: MediaError }) => void) | undefined,
|
|
162
|
+
|
|
163
|
+
/** Entered fullscreen */
|
|
164
|
+
onenterfullscreen = undefined as (() => void) | undefined,
|
|
165
|
+
|
|
166
|
+
/** Exited fullscreen */
|
|
167
|
+
onexitfullscreen = undefined as (() => void) | undefined,
|
|
168
|
+
|
|
169
|
+
/** Entered PiP */
|
|
170
|
+
onenterpip = undefined as (() => void) | undefined,
|
|
171
|
+
|
|
172
|
+
/** Exited PiP */
|
|
173
|
+
onexitpip = undefined as (() => void) | undefined,
|
|
174
|
+
|
|
175
|
+
/** Video element ready */
|
|
176
|
+
onready = undefined as ((detail: { player: HTMLVideoElement }) => void) | undefined,
|
|
177
|
+
}: {
|
|
178
|
+
src: string | Source[];
|
|
179
|
+
poster?: string;
|
|
180
|
+
autoplay?: boolean;
|
|
181
|
+
muted?: boolean;
|
|
182
|
+
loop?: boolean;
|
|
183
|
+
controls?: boolean;
|
|
184
|
+
aspect_ratio?: string;
|
|
185
|
+
preload?: 'auto' | 'metadata' | 'none';
|
|
186
|
+
captions?: Track[];
|
|
187
|
+
thumbnails?: string;
|
|
188
|
+
skeleton?: boolean;
|
|
189
|
+
id?: string;
|
|
190
|
+
class?: string;
|
|
191
|
+
element?: HTMLElement | undefined;
|
|
192
|
+
player?: HTMLVideoElement | undefined;
|
|
193
|
+
onplay?: () => void;
|
|
194
|
+
onpause?: () => void;
|
|
195
|
+
onended?: () => void;
|
|
196
|
+
ontimeupdate?: (detail: { currentTime: number; duration: number }) => void;
|
|
197
|
+
onerror?: (detail: { error: MediaError }) => void;
|
|
198
|
+
onenterfullscreen?: () => void;
|
|
199
|
+
onexitfullscreen?: () => void;
|
|
200
|
+
onenterpip?: () => void;
|
|
201
|
+
onexitpip?: () => void;
|
|
202
|
+
onready?: (detail: { player: HTMLVideoElement }) => void;
|
|
203
|
+
} = $props();
|
|
204
|
+
|
|
205
|
+
// --- State ---
|
|
206
|
+
let playing = $state(false);
|
|
207
|
+
let current_time = $state(0);
|
|
208
|
+
let duration = $state(0);
|
|
209
|
+
let buffered_end = $state(0);
|
|
210
|
+
let volume = $state(1);
|
|
211
|
+
let is_muted = $state(muted);
|
|
212
|
+
let is_fullscreen = $state(false);
|
|
213
|
+
let is_pip = $state(false);
|
|
214
|
+
let captions_active = $state(false);
|
|
215
|
+
let show_controls = $state(true);
|
|
216
|
+
let has_started = $state(false);
|
|
217
|
+
let has_error = $state(false);
|
|
218
|
+
let is_ready = $state(false);
|
|
219
|
+
|
|
220
|
+
// True while the user is actively dragging the seek Range. Suppresses
|
|
221
|
+
// timeupdate / rAF writes to `current_time` so the thumb tracks the pointer
|
|
222
|
+
// instead of fighting the (async) seeks echoing back from the element.
|
|
223
|
+
let is_scrubbing = $state(false);
|
|
224
|
+
|
|
225
|
+
// Seek requested before the media had metadata (duration unknown /
|
|
226
|
+
// readyState < HAVE_METADATA — setting currentTime then is ignored).
|
|
227
|
+
// `fraction` targets a 0..1 position of the eventual duration (seek bar);
|
|
228
|
+
// `time` targets an absolute second (keyboard / frame step). Applied once
|
|
229
|
+
// metadata (and therefore duration) arrives.
|
|
230
|
+
let pending_seek = $state<{ kind: 'fraction' | 'time'; value: number } | undefined>(
|
|
231
|
+
undefined,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// One-shot guard so a pre-metadata scrub triggers at most one load() kick.
|
|
235
|
+
let metadata_requested = false;
|
|
236
|
+
|
|
237
|
+
// Menus / popovers (all inline so they survive fullscreen)
|
|
238
|
+
let quality_open = $state(false);
|
|
239
|
+
let speed_open = $state(false);
|
|
240
|
+
let settings_open = $state(false);
|
|
241
|
+
|
|
242
|
+
// Seek hover preview (independent of the Range slider)
|
|
243
|
+
let seek_hover_time = $state(0);
|
|
244
|
+
let seek_hover_x = $state(0);
|
|
245
|
+
let show_seek_tooltip = $state(false);
|
|
246
|
+
let seek_el = $state<HTMLElement | undefined>(undefined);
|
|
247
|
+
|
|
248
|
+
// Settings popover refs (for focus management)
|
|
249
|
+
let settings_btn = $state<HTMLElement | undefined>(undefined);
|
|
250
|
+
let settings_pop = $state<HTMLElement | undefined>(undefined);
|
|
251
|
+
|
|
252
|
+
// Thumbnail track parsed cues
|
|
253
|
+
interface ThumbCue {
|
|
254
|
+
start: number;
|
|
255
|
+
end: number;
|
|
256
|
+
src: string;
|
|
257
|
+
xywh?: [number, number, number, number];
|
|
258
|
+
}
|
|
259
|
+
let thumb_cues = $state<ThumbCue[]>([]);
|
|
260
|
+
|
|
261
|
+
function parseTimestamp(ts: string): number {
|
|
262
|
+
const parts = ts.split(':').map((p) => parseFloat(p));
|
|
263
|
+
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
264
|
+
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
|
265
|
+
return parts[0] || 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function loadThumbnails(url: string) {
|
|
269
|
+
try {
|
|
270
|
+
const res = await fetch(url);
|
|
271
|
+
if (!res.ok) return;
|
|
272
|
+
const text = await res.text();
|
|
273
|
+
const lines = text.split(/\r?\n/);
|
|
274
|
+
const cues: ThumbCue[] = [];
|
|
275
|
+
const baseURL = new URL(url, window.location.href);
|
|
276
|
+
for (let i = 0; i < lines.length; i++) {
|
|
277
|
+
const line = lines[i];
|
|
278
|
+
const m = line.match(
|
|
279
|
+
/(\d+(?::\d+){0,2}(?:\.\d+)?)\s*-->\s*(\d+(?::\d+){0,2}(?:\.\d+)?)/,
|
|
280
|
+
);
|
|
281
|
+
if (!m) continue;
|
|
282
|
+
const start = parseTimestamp(m[1]);
|
|
283
|
+
const end = parseTimestamp(m[2]);
|
|
284
|
+
const next = lines[i + 1]?.trim();
|
|
285
|
+
if (!next) continue;
|
|
286
|
+
const hashIdx = next.indexOf('#xywh=');
|
|
287
|
+
let src = next;
|
|
288
|
+
let xywh: [number, number, number, number] | undefined;
|
|
289
|
+
if (hashIdx !== -1) {
|
|
290
|
+
src = next.slice(0, hashIdx);
|
|
291
|
+
const parts = next
|
|
292
|
+
.slice(hashIdx + 6)
|
|
293
|
+
.split(',')
|
|
294
|
+
.map((n) => parseInt(n, 10));
|
|
295
|
+
if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
|
|
296
|
+
xywh = parts as [number, number, number, number];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const resolved = new URL(src, baseURL).href;
|
|
300
|
+
cues.push({ start, end, src: resolved, xywh });
|
|
301
|
+
i++;
|
|
302
|
+
}
|
|
303
|
+
thumb_cues = cues;
|
|
304
|
+
} catch {
|
|
305
|
+
thumb_cues = [];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
$effect(() => {
|
|
310
|
+
if (thumbnails) loadThumbnails(thumbnails);
|
|
311
|
+
else thumb_cues = [];
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const active_thumb = $derived.by<ThumbCue | undefined>(() => {
|
|
315
|
+
if (!thumb_cues.length || !show_seek_tooltip) return undefined;
|
|
316
|
+
const t = seek_hover_time;
|
|
317
|
+
// Cues are usually sorted; linear scan is fine for typical sizes.
|
|
318
|
+
for (const c of thumb_cues) {
|
|
319
|
+
if (t >= c.start && t < c.end) return c;
|
|
320
|
+
}
|
|
321
|
+
return thumb_cues[thumb_cues.length - 1];
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Inactivity timer
|
|
325
|
+
let inactivity_timer: ReturnType<typeof setTimeout> | undefined;
|
|
326
|
+
|
|
327
|
+
// PiP support
|
|
328
|
+
let pip_supported = $state(false);
|
|
329
|
+
|
|
330
|
+
// Playback speeds
|
|
331
|
+
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
|
332
|
+
let playback_rate = $state(1);
|
|
333
|
+
|
|
334
|
+
// --- HLS state ---
|
|
335
|
+
interface HlsLevel {
|
|
336
|
+
index: number;
|
|
337
|
+
height: number;
|
|
338
|
+
bitrate: number;
|
|
339
|
+
}
|
|
340
|
+
let hls_instance: HlsInstance | undefined;
|
|
341
|
+
let hls_levels = $state<HlsLevel[]>([]);
|
|
342
|
+
// User-selected level: -1 = automatic (adaptive bitrate).
|
|
343
|
+
let hls_current_level = $state(-1);
|
|
344
|
+
|
|
345
|
+
// --- Derived ---
|
|
346
|
+
const has_src = $derived(
|
|
347
|
+
typeof src === 'string'
|
|
348
|
+
? src.trim().length > 0
|
|
349
|
+
: Array.isArray(src) && src.length > 0,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// Skeleton stands in for a not-yet-known source. Providing a `src` turns it
|
|
353
|
+
// off, even if the caller leaves `skeleton` true.
|
|
354
|
+
const show_skeleton = $derived(skeleton && !has_src);
|
|
355
|
+
|
|
356
|
+
const sources = $derived(
|
|
357
|
+
typeof src === 'string' ? [{ src, type: inferType(src) }] : src,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const has_quality_options = $derived(
|
|
361
|
+
Array.isArray(src) && src.length > 1 && src.some((s) => s.size != null),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const quality_sources = $derived(
|
|
365
|
+
has_quality_options
|
|
366
|
+
? (src as Source[])
|
|
367
|
+
.filter((s) => s.size != null)
|
|
368
|
+
.sort((a, b) => (b.size ?? 0) - (a.size ?? 0))
|
|
369
|
+
: [],
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
let active_source_index = $state(0);
|
|
373
|
+
|
|
374
|
+
const active_source = $derived(
|
|
375
|
+
has_quality_options ? quality_sources[active_source_index] : sources[0],
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const active_quality_label = $derived(
|
|
379
|
+
active_source?.size ? `${active_source.size}p` : '',
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// HLS is delivered as a single playlist URL; quality comes from the manifest
|
|
383
|
+
// (handled by hls.js / the platform), not from the `Source[]` `size` field.
|
|
384
|
+
const is_hls_source = $derived(
|
|
385
|
+
!!active_source && (isHlsType(active_source.type) || isHlsUrl(active_source.src)),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const hls_quality_label = $derived(
|
|
389
|
+
hls_current_level === -1
|
|
390
|
+
? 'Auto'
|
|
391
|
+
: `${hls_levels.find((l) => l.index === hls_current_level)?.height ?? ''}p`,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// Whether a quality control should exist at all (array-based or HLS manifest).
|
|
395
|
+
const has_quality = $derived(has_quality_options || hls_levels.length > 1);
|
|
396
|
+
|
|
397
|
+
const progress_percent = $derived(duration > 0 ? (current_time / duration) * 100 : 0);
|
|
398
|
+
const buffered_percent = $derived(duration > 0 ? (buffered_end / duration) * 100 : 0);
|
|
399
|
+
|
|
400
|
+
// Seek Range scale. Before metadata the duration is unknown, so the bar
|
|
401
|
+
// runs 0..1 and values are *fractions* of the eventual duration — positions
|
|
402
|
+
// stay meaningful and a queued seek can be resolved once metadata lands.
|
|
403
|
+
const seek_max = $derived(duration > 0 ? duration : 1);
|
|
404
|
+
const seek_step = $derived(duration > 0 ? 0.1 : 0.001);
|
|
405
|
+
|
|
406
|
+
// --- Responsive collapse ranks ---
|
|
407
|
+
// Controls collapse into the settings popover lowest-priority first. Rank 1 =
|
|
408
|
+
// first to collapse. Ranks are assigned only to controls that actually exist,
|
|
409
|
+
// so the set stays dense (1..N) as PiP / HLS-quality appear after detection.
|
|
410
|
+
// Container-query breakpoints (in CSS) key off these rank classes, which lets
|
|
411
|
+
// the hiding be pure CSS — SSR-safe and flash-free — while the "never exactly
|
|
412
|
+
// one item in the popover" guarantee holds (ranks 1 & 2 collapse together).
|
|
413
|
+
const COLLAPSE_ORDER = [
|
|
414
|
+
'pip',
|
|
415
|
+
'captions',
|
|
416
|
+
'speed',
|
|
417
|
+
'quality',
|
|
418
|
+
'fullscreen',
|
|
419
|
+
'volume',
|
|
420
|
+
] as const;
|
|
421
|
+
const present_controls = $derived<Record<string, boolean>>({
|
|
422
|
+
volume: true,
|
|
423
|
+
fullscreen: true,
|
|
424
|
+
speed: true,
|
|
425
|
+
quality: has_quality,
|
|
426
|
+
captions: captions.length > 0,
|
|
427
|
+
pip: pip_supported,
|
|
428
|
+
});
|
|
429
|
+
const ranks = $derived.by<Record<string, number>>(() => {
|
|
430
|
+
const order = COLLAPSE_ORDER.filter((k) => present_controls[k]);
|
|
431
|
+
const map: Record<string, number> = {};
|
|
432
|
+
order.forEach((k, i) => (map[k] = i + 1));
|
|
433
|
+
return map;
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// --- Helpers ---
|
|
437
|
+
function formatTime(seconds: number): string {
|
|
438
|
+
if (!isFinite(seconds) || isNaN(seconds)) return '0:00';
|
|
439
|
+
const h = Math.floor(seconds / 3600);
|
|
440
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
441
|
+
const s = Math.floor(seconds % 60);
|
|
442
|
+
if (h > 0)
|
|
443
|
+
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
444
|
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const any_menu_open = $derived(quality_open || speed_open || settings_open);
|
|
448
|
+
|
|
449
|
+
function resetInactivityTimer() {
|
|
450
|
+
show_controls = true;
|
|
451
|
+
clearTimeout(inactivity_timer);
|
|
452
|
+
if (playing) {
|
|
453
|
+
inactivity_timer = setTimeout(() => {
|
|
454
|
+
if (playing && !any_menu_open) {
|
|
455
|
+
show_controls = false;
|
|
456
|
+
}
|
|
457
|
+
}, 2500);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function closeAllMenus() {
|
|
462
|
+
quality_open = false;
|
|
463
|
+
speed_open = false;
|
|
464
|
+
settings_open = false;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// --- Actions ---
|
|
468
|
+
function togglePlay() {
|
|
469
|
+
if (!player) return;
|
|
470
|
+
if (player.paused) {
|
|
471
|
+
player.play();
|
|
472
|
+
} else {
|
|
473
|
+
player.pause();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function seek(time: number) {
|
|
478
|
+
if (!player) return;
|
|
479
|
+
if (duration > 0 && player.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
|
480
|
+
const t = Math.max(0, Math.min(time, duration));
|
|
481
|
+
player.currentTime = t;
|
|
482
|
+
current_time = t;
|
|
483
|
+
} else {
|
|
484
|
+
// Metadata not loaded yet — queue the target and make sure metadata
|
|
485
|
+
// is on its way. UI updates optimistically; playback stays paused.
|
|
486
|
+
const t = Math.max(0, time);
|
|
487
|
+
current_time = t;
|
|
488
|
+
pending_seek = { kind: 'time', value: t };
|
|
489
|
+
ensureMetadata();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Kick off a metadata load when the media hasn't fetched any yet (e.g.
|
|
494
|
+
* `preload="none"`), so a pre-play seek can actually resolve. */
|
|
495
|
+
function ensureMetadata() {
|
|
496
|
+
if (!player || metadata_requested) return;
|
|
497
|
+
if (player.readyState >= HTMLMediaElement.HAVE_METADATA) return;
|
|
498
|
+
metadata_requested = true;
|
|
499
|
+
if (player.preload === 'none') player.preload = 'metadata';
|
|
500
|
+
// HLS attaches/loads through its own effect — never call load() on it.
|
|
501
|
+
// Only restart the resource selection when nothing is being fetched.
|
|
502
|
+
if (
|
|
503
|
+
!is_hls_source &&
|
|
504
|
+
(player.networkState === HTMLMediaElement.NETWORK_EMPTY ||
|
|
505
|
+
player.networkState === HTMLMediaElement.NETWORK_IDLE)
|
|
506
|
+
) {
|
|
507
|
+
player.load();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Apply a queued pre-metadata seek once the duration is known. Stays
|
|
512
|
+
* paused — like standard players, seeking while paused shows the new
|
|
513
|
+
* frame without starting playback. */
|
|
514
|
+
function applyPendingSeek() {
|
|
515
|
+
if (!pending_seek || !player || !(duration > 0)) return;
|
|
516
|
+
const target =
|
|
517
|
+
pending_seek.kind === 'fraction'
|
|
518
|
+
? pending_seek.value * duration
|
|
519
|
+
: Math.min(pending_seek.value, duration);
|
|
520
|
+
pending_seek = undefined;
|
|
521
|
+
player.currentTime = target;
|
|
522
|
+
current_time = target;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function setVolume(v: number) {
|
|
526
|
+
if (!player) return;
|
|
527
|
+
volume = Math.max(0, Math.min(1, v));
|
|
528
|
+
player.volume = volume;
|
|
529
|
+
if (volume > 0 && is_muted) {
|
|
530
|
+
is_muted = false;
|
|
531
|
+
player.muted = false;
|
|
532
|
+
muted = false;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function toggleMute() {
|
|
537
|
+
if (!player) return;
|
|
538
|
+
is_muted = !is_muted;
|
|
539
|
+
player.muted = is_muted;
|
|
540
|
+
muted = is_muted;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function toggleFullscreen() {
|
|
544
|
+
if (!element) return;
|
|
545
|
+
if (!document.fullscreenElement) {
|
|
546
|
+
element.requestFullscreen().catch(() => {});
|
|
547
|
+
} else {
|
|
548
|
+
document.exitFullscreen().catch(() => {});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function togglePip() {
|
|
553
|
+
if (!player) return;
|
|
554
|
+
if (document.pictureInPictureElement) {
|
|
555
|
+
document.exitPictureInPicture().catch(() => {});
|
|
556
|
+
} else {
|
|
557
|
+
player.requestPictureInPicture().catch(() => {});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function toggleCaptions() {
|
|
562
|
+
if (!player) return;
|
|
563
|
+
captions_active = !captions_active;
|
|
564
|
+
for (let i = 0; i < player.textTracks.length; i++) {
|
|
565
|
+
const track = player.textTracks[i];
|
|
566
|
+
if (track.kind === 'captions' || track.kind === 'subtitles') {
|
|
567
|
+
track.mode = captions_active ? 'showing' : 'hidden';
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function selectQuality(index: number) {
|
|
573
|
+
if (!player || index === active_source_index) {
|
|
574
|
+
quality_open = false;
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const was_playing = !player.paused;
|
|
578
|
+
const time = player.currentTime;
|
|
579
|
+
active_source_index = index;
|
|
580
|
+
// Source change happens via reactive update; restore time after load
|
|
581
|
+
const restore = () => {
|
|
582
|
+
if (!player) return;
|
|
583
|
+
player.currentTime = time;
|
|
584
|
+
if (was_playing) player.play();
|
|
585
|
+
player.removeEventListener('loadeddata', restore);
|
|
586
|
+
};
|
|
587
|
+
// Need a tick for the source to update, then load
|
|
588
|
+
requestAnimationFrame(() => {
|
|
589
|
+
if (!player) return;
|
|
590
|
+
player.load();
|
|
591
|
+
player.addEventListener('loadeddata', restore);
|
|
592
|
+
});
|
|
593
|
+
quality_open = false;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function selectSpeed(speed: number) {
|
|
597
|
+
if (!player) return;
|
|
598
|
+
playback_rate = speed;
|
|
599
|
+
player.playbackRate = speed;
|
|
600
|
+
speed_open = false;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function selectHlsLevel(index: number) {
|
|
604
|
+
hls_current_level = index;
|
|
605
|
+
if (hls_instance) hls_instance.currentLevel = index; // -1 re-enables auto
|
|
606
|
+
quality_open = false;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Frame stepping — the platform doesn't expose the true frame rate, so assume
|
|
610
|
+
// ~30fps. Stepping implies the user wants to inspect frames, so pause first.
|
|
611
|
+
const FRAME = 1 / 30;
|
|
612
|
+
function stepFrame(dir: number) {
|
|
613
|
+
if (!player) return;
|
|
614
|
+
if (!player.paused) player.pause();
|
|
615
|
+
seek((player.currentTime || current_time) + dir * FRAME);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Arrow up/down on the speed / quality selector adjusts the value in place
|
|
619
|
+
// (without needing to open the menu).
|
|
620
|
+
function cycleSpeed(dir: number) {
|
|
621
|
+
const i = SPEEDS.indexOf(playback_rate);
|
|
622
|
+
const next = Math.max(0, Math.min(SPEEDS.length - 1, (i === -1 ? 2 : i) + dir));
|
|
623
|
+
selectSpeed(SPEEDS[next]);
|
|
624
|
+
}
|
|
625
|
+
function cycleQuality(dir: number) {
|
|
626
|
+
if (has_quality_options) {
|
|
627
|
+
// quality_sources is sorted high→low, so "up" (higher quality) = lower index
|
|
628
|
+
const next = Math.max(
|
|
629
|
+
0,
|
|
630
|
+
Math.min(quality_sources.length - 1, active_source_index - dir),
|
|
631
|
+
);
|
|
632
|
+
selectQuality(next);
|
|
633
|
+
} else if (hls_levels.length > 1) {
|
|
634
|
+
// Ordered options: Auto, then levels high→low. Up moves toward Auto/higher.
|
|
635
|
+
const opts = [-1, ...hls_levels.map((l) => l.index)];
|
|
636
|
+
const cur = Math.max(0, opts.indexOf(hls_current_level));
|
|
637
|
+
const next = Math.max(0, Math.min(opts.length - 1, cur - dir));
|
|
638
|
+
selectHlsLevel(opts[next]);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
function onSelectorKeydown(kind: 'speed' | 'quality', e: KeyboardEvent) {
|
|
642
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
|
643
|
+
e.preventDefault();
|
|
644
|
+
const dir = e.key === 'ArrowUp' ? 1 : -1;
|
|
645
|
+
if (kind === 'speed') cycleSpeed(dir);
|
|
646
|
+
else cycleQuality(dir);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// --- Seek bar (Range-driven) ---
|
|
650
|
+
// Range only emits oninput/onchange for USER interaction (programmatic
|
|
651
|
+
// `value` prop updates never echo back), so writing player.currentTime here
|
|
652
|
+
// cannot create a feedback loop with timeupdate.
|
|
653
|
+
function onSeekInput(value: number) {
|
|
654
|
+
resetInactivityTimer();
|
|
655
|
+
if (!player) return;
|
|
656
|
+
is_scrubbing = true;
|
|
657
|
+
// Optimistic UI — the thumb stays where the user put it, even when the
|
|
658
|
+
// actual seek is queued or the element is still completing a prior seek.
|
|
659
|
+
current_time = value;
|
|
660
|
+
if (duration > 0 && player.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
|
661
|
+
pending_seek = undefined;
|
|
662
|
+
player.currentTime = value;
|
|
663
|
+
} else if (duration > 0) {
|
|
664
|
+
// Duration known but media not seekable yet (e.g. mid source swap).
|
|
665
|
+
pending_seek = { kind: 'time', value };
|
|
666
|
+
ensureMetadata();
|
|
667
|
+
} else {
|
|
668
|
+
// No metadata: the bar runs 0..1, so the value *is* the fraction.
|
|
669
|
+
pending_seek = {
|
|
670
|
+
kind: 'fraction',
|
|
671
|
+
value: Math.max(0, Math.min(1, value / seek_max)),
|
|
672
|
+
};
|
|
673
|
+
ensureMetadata();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Drag released (or click/keyboard committed) — perform the final seek and
|
|
678
|
+
// hand `current_time` back to playback-driven updates.
|
|
679
|
+
function onSeekCommit(value: number) {
|
|
680
|
+
onSeekInput(value);
|
|
681
|
+
is_scrubbing = false;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// --- Volume (Range-driven) ---
|
|
685
|
+
function onVolumeInput(value: number) {
|
|
686
|
+
setVolume(value / 100);
|
|
687
|
+
resetInactivityTimer();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// --- Seek hover preview tooltip ---
|
|
691
|
+
// Tracks the pointer over the seek area without intercepting Range's own
|
|
692
|
+
// pointer handling (events bubble up; the tooltip is pointer-events:none).
|
|
693
|
+
function updateSeekHover(e: PointerEvent) {
|
|
694
|
+
if (!seek_el || !(duration > 0)) return;
|
|
695
|
+
const rect = seek_el.getBoundingClientRect();
|
|
696
|
+
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
|
697
|
+
const pct = rect.width > 0 ? x / rect.width : 0;
|
|
698
|
+
seek_hover_time = pct * duration;
|
|
699
|
+
seek_hover_x = x;
|
|
700
|
+
show_seek_tooltip = true;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// --- Keyboard shortcuts ---
|
|
704
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
705
|
+
if (!player) return;
|
|
706
|
+
|
|
707
|
+
// Ignore keyboard when typing in an input or operating a specific control
|
|
708
|
+
// (buttons / range sliders manage their own keys).
|
|
709
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
710
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON')
|
|
711
|
+
return;
|
|
712
|
+
// While a menu is open let it own arrow/escape navigation.
|
|
713
|
+
if (any_menu_open && e.key !== ' ' && e.key !== 'k' && e.key !== 'K') return;
|
|
714
|
+
|
|
715
|
+
let handled = true;
|
|
716
|
+
|
|
717
|
+
switch (e.key) {
|
|
718
|
+
case ' ':
|
|
719
|
+
case 'k':
|
|
720
|
+
case 'K':
|
|
721
|
+
togglePlay();
|
|
722
|
+
break;
|
|
723
|
+
case 'ArrowLeft':
|
|
724
|
+
seek(current_time - 10);
|
|
725
|
+
break;
|
|
726
|
+
case 'ArrowRight':
|
|
727
|
+
seek(current_time + 10);
|
|
728
|
+
break;
|
|
729
|
+
case 'ArrowUp':
|
|
730
|
+
setVolume(volume + 0.1);
|
|
731
|
+
break;
|
|
732
|
+
case 'ArrowDown':
|
|
733
|
+
setVolume(volume - 0.1);
|
|
734
|
+
break;
|
|
735
|
+
case 'm':
|
|
736
|
+
case 'M':
|
|
737
|
+
toggleMute();
|
|
738
|
+
break;
|
|
739
|
+
case 'f':
|
|
740
|
+
case 'F':
|
|
741
|
+
toggleFullscreen();
|
|
742
|
+
break;
|
|
743
|
+
case 'c':
|
|
744
|
+
case 'C':
|
|
745
|
+
if (captions.length > 0) toggleCaptions();
|
|
746
|
+
break;
|
|
747
|
+
case ',':
|
|
748
|
+
stepFrame(-1);
|
|
749
|
+
break;
|
|
750
|
+
case '.':
|
|
751
|
+
stepFrame(1);
|
|
752
|
+
break;
|
|
753
|
+
default:
|
|
754
|
+
handled = false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (handled) {
|
|
758
|
+
e.preventDefault();
|
|
759
|
+
e.stopPropagation();
|
|
760
|
+
resetInactivityTimer();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// --- Settings popover keyboard ---
|
|
765
|
+
function toggleSettings() {
|
|
766
|
+
settings_open = !settings_open;
|
|
767
|
+
quality_open = false;
|
|
768
|
+
speed_open = false;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function handleSettingsKeydown(e: KeyboardEvent) {
|
|
772
|
+
if (e.key === 'Escape') {
|
|
773
|
+
e.stopPropagation();
|
|
774
|
+
settings_open = false;
|
|
775
|
+
settings_btn?.focus();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
779
|
+
e.preventDefault();
|
|
780
|
+
const items = Array.from(
|
|
781
|
+
settings_pop?.querySelectorAll<HTMLElement>('[data-pop-focusable]') ?? [],
|
|
782
|
+
);
|
|
783
|
+
if (!items.length) return;
|
|
784
|
+
const idx = items.indexOf(document.activeElement as HTMLElement);
|
|
785
|
+
const next =
|
|
786
|
+
e.key === 'ArrowDown'
|
|
787
|
+
? items[(idx + 1) % items.length]
|
|
788
|
+
: items[(idx - 1 + items.length) % items.length];
|
|
789
|
+
next?.focus();
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Move focus into the popover when it opens.
|
|
794
|
+
$effect(() => {
|
|
795
|
+
if (!settings_open || !settings_pop) return;
|
|
796
|
+
const first = settings_pop.querySelector<HTMLElement>('[data-pop-focusable]');
|
|
797
|
+
first?.focus();
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// --- Video event handlers ---
|
|
801
|
+
function handleVideoPlay() {
|
|
802
|
+
playing = true;
|
|
803
|
+
has_started = true;
|
|
804
|
+
resetInactivityTimer();
|
|
805
|
+
onplay?.();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function handleVideoPause() {
|
|
809
|
+
playing = false;
|
|
810
|
+
show_controls = true;
|
|
811
|
+
clearTimeout(inactivity_timer);
|
|
812
|
+
onpause?.();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function handleVideoEnded() {
|
|
816
|
+
playing = false;
|
|
817
|
+
has_started = false;
|
|
818
|
+
show_controls = true;
|
|
819
|
+
clearTimeout(inactivity_timer);
|
|
820
|
+
onended?.();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function handleVideoTimeUpdate() {
|
|
824
|
+
if (!player) return;
|
|
825
|
+
// `loadedmetadata` is supposed to be the canonical place to read
|
|
826
|
+
// duration, but some browser/codec combos fire `timeupdate` first
|
|
827
|
+
// (or never fire `loadedmetadata` at all for already-cached files).
|
|
828
|
+
// Mirror duration here too so the progress bar can't get stuck at 0.
|
|
829
|
+
// Resolve this BEFORE touching `current_time` so the thumb is only ever
|
|
830
|
+
// driven against a real `seek_max`.
|
|
831
|
+
if (duration === 0 && isFinite(player.duration) && player.duration > 0) {
|
|
832
|
+
duration = player.duration;
|
|
833
|
+
applyPendingSeek();
|
|
834
|
+
}
|
|
835
|
+
// While the user is scrubbing — or a pre-metadata seek is still queued —
|
|
836
|
+
// the element's currentTime lags the user's intent; reflecting it back
|
|
837
|
+
// into `current_time` would yank the thumb around. Hold the optimistic
|
|
838
|
+
// value until release / until the queued seek is applied.
|
|
839
|
+
//
|
|
840
|
+
// Also hold while `duration` is still 0 (first play, metadata not yet
|
|
841
|
+
// resolved): the seek bar runs on the pre-metadata 0..1 scale, so an
|
|
842
|
+
// advancing currentTime (e.g. 0.2s) is divided by max=1 and spikes the
|
|
843
|
+
// thumb to ~20% — then snaps back once the real duration lands, an
|
|
844
|
+
// animated jump the track's `width`/`left` transition makes visible.
|
|
845
|
+
// Don't let playback drive the thumb until the scale is real; this makes
|
|
846
|
+
// first play look identical to subsequent plays.
|
|
847
|
+
if (!is_scrubbing && !pending_seek && duration > 0) {
|
|
848
|
+
current_time = player.currentTime;
|
|
849
|
+
}
|
|
850
|
+
ontimeupdate?.({ currentTime: player.currentTime, duration: player.duration });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function handleVideoDurationChange() {
|
|
854
|
+
if (!player) return;
|
|
855
|
+
if (isFinite(player.duration) && player.duration > 0) {
|
|
856
|
+
duration = player.duration;
|
|
857
|
+
applyPendingSeek();
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function handleVideoLoadedMetadata() {
|
|
862
|
+
if (!player) return;
|
|
863
|
+
if (isFinite(player.duration) && player.duration > 0) {
|
|
864
|
+
duration = player.duration;
|
|
865
|
+
}
|
|
866
|
+
is_ready = true;
|
|
867
|
+
metadata_requested = false;
|
|
868
|
+
applyPendingSeek();
|
|
869
|
+
pip_supported =
|
|
870
|
+
'pictureInPictureEnabled' in document && document.pictureInPictureEnabled;
|
|
871
|
+
onready?.({ player });
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function handleVideoProgress() {
|
|
875
|
+
if (!player || player.buffered.length === 0) return;
|
|
876
|
+
buffered_end = player.buffered.end(player.buffered.length - 1);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function handleVideoVolumeChange() {
|
|
880
|
+
if (!player) return;
|
|
881
|
+
volume = player.volume;
|
|
882
|
+
is_muted = player.muted;
|
|
883
|
+
muted = player.muted;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function handleVideoError() {
|
|
887
|
+
has_error = true;
|
|
888
|
+
if (player?.error) {
|
|
889
|
+
onerror?.({ error: player.error });
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// --- Fullscreen change ---
|
|
894
|
+
$effect(() => {
|
|
895
|
+
function onFullscreenChange() {
|
|
896
|
+
const was = is_fullscreen;
|
|
897
|
+
is_fullscreen =
|
|
898
|
+
!!document.fullscreenElement && document.fullscreenElement === element;
|
|
899
|
+
if (is_fullscreen && !was) onenterfullscreen?.();
|
|
900
|
+
if (!is_fullscreen && was) onexitfullscreen?.();
|
|
901
|
+
}
|
|
902
|
+
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
903
|
+
return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// --- PiP change ---
|
|
907
|
+
$effect(() => {
|
|
908
|
+
if (!player) return;
|
|
909
|
+
const vid = player;
|
|
910
|
+
function onEnterPip() {
|
|
911
|
+
is_pip = true;
|
|
912
|
+
onenterpip?.();
|
|
913
|
+
}
|
|
914
|
+
function onLeavePip() {
|
|
915
|
+
is_pip = false;
|
|
916
|
+
onexitpip?.();
|
|
917
|
+
}
|
|
918
|
+
vid.addEventListener('enterpictureinpicture', onEnterPip);
|
|
919
|
+
vid.addEventListener('leavepictureinpicture', onLeavePip);
|
|
920
|
+
return () => {
|
|
921
|
+
vid.removeEventListener('enterpictureinpicture', onEnterPip);
|
|
922
|
+
vid.removeEventListener('leavepictureinpicture', onLeavePip);
|
|
923
|
+
};
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
// --- HLS attachment ---
|
|
927
|
+
// HLS playlists are never rendered as a <source> child (the browser can't
|
|
928
|
+
// load them, and it would error on non-Safari). Instead we wire the stream
|
|
929
|
+
// up here, client-side only: natively on Safari/iOS, otherwise via a
|
|
930
|
+
// lazily-imported hls.js. Either way the platform feeds our custom controls,
|
|
931
|
+
// so the native `controls` attribute is never set.
|
|
932
|
+
$effect(() => {
|
|
933
|
+
const vid = player;
|
|
934
|
+
if (!vid || !is_hls_source) return;
|
|
935
|
+
const url = active_source.src;
|
|
936
|
+
|
|
937
|
+
// Native HLS — hand the URL straight to the media element.
|
|
938
|
+
if (supportsNativeHls()) {
|
|
939
|
+
vid.src = url;
|
|
940
|
+
vid.load();
|
|
941
|
+
return () => {
|
|
942
|
+
vid.removeAttribute('src');
|
|
943
|
+
vid.load();
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Everywhere else — pull in hls.js on demand and attach it.
|
|
948
|
+
let cancelled = false;
|
|
949
|
+
let instance: HlsInstance | undefined;
|
|
950
|
+
|
|
951
|
+
(async () => {
|
|
952
|
+
try {
|
|
953
|
+
// @ts-ignore — hls.js is an optional peer dependency, loaded on demand
|
|
954
|
+
const mod: { default: HlsStatic } = await import('hls.js');
|
|
955
|
+
const Hls = mod.default;
|
|
956
|
+
if (cancelled || player !== vid) return;
|
|
957
|
+
if (!Hls.isSupported()) {
|
|
958
|
+
// No native HLS and no Media Source Extensions — nothing we can do.
|
|
959
|
+
has_error = true;
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
instance = new Hls();
|
|
963
|
+
hls_instance = instance;
|
|
964
|
+
|
|
965
|
+
instance.on(Hls.Events.MANIFEST_PARSED, (_e, data) => {
|
|
966
|
+
hls_levels = (data.levels ?? [])
|
|
967
|
+
.map((l, i) => ({ index: i, height: l.height ?? 0, bitrate: l.bitrate ?? 0 }))
|
|
968
|
+
.filter((l) => l.height > 0)
|
|
969
|
+
.sort((a, b) => b.height - a.height);
|
|
970
|
+
hls_current_level = -1;
|
|
971
|
+
// The `autoplay` attribute can be missed when media is attached
|
|
972
|
+
// after load, so kick playback off explicitly when requested.
|
|
973
|
+
if (autoplay) vid.play().catch(() => {});
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
instance.on(Hls.Events.ERROR, (_e, data) => {
|
|
977
|
+
if (!data.fatal) return;
|
|
978
|
+
// Attempt the standard recoveries before surfacing an error.
|
|
979
|
+
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
|
980
|
+
instance?.startLoad();
|
|
981
|
+
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
|
982
|
+
instance?.recoverMediaError();
|
|
983
|
+
} else {
|
|
984
|
+
has_error = true;
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
instance.loadSource(url);
|
|
989
|
+
instance.attachMedia(vid);
|
|
990
|
+
} catch {
|
|
991
|
+
has_error = true;
|
|
992
|
+
}
|
|
993
|
+
})();
|
|
994
|
+
|
|
995
|
+
return () => {
|
|
996
|
+
cancelled = true;
|
|
997
|
+
if (instance) {
|
|
998
|
+
try {
|
|
999
|
+
instance.destroy();
|
|
1000
|
+
} catch {
|
|
1001
|
+
// already torn down
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
if (hls_instance === instance) hls_instance = undefined;
|
|
1005
|
+
hls_levels = [];
|
|
1006
|
+
hls_current_level = -1;
|
|
1007
|
+
};
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// --- Sync muted prop ---
|
|
1011
|
+
$effect(() => {
|
|
1012
|
+
if (player) {
|
|
1013
|
+
player.muted = muted;
|
|
1014
|
+
is_muted = muted;
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
// --- Enable default captions ---
|
|
1019
|
+
$effect(() => {
|
|
1020
|
+
if (!player) return;
|
|
1021
|
+
for (let i = 0; i < player.textTracks.length; i++) {
|
|
1022
|
+
const track = player.textTracks[i];
|
|
1023
|
+
if (track.kind === 'captions' || track.kind === 'subtitles') {
|
|
1024
|
+
// Check if this track was marked as default
|
|
1025
|
+
const caption = captions[i];
|
|
1026
|
+
if (caption?.default) {
|
|
1027
|
+
track.mode = 'showing';
|
|
1028
|
+
captions_active = true;
|
|
1029
|
+
} else {
|
|
1030
|
+
track.mode = 'hidden';
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
// --- Close menus on outside click ---
|
|
1037
|
+
$effect(() => {
|
|
1038
|
+
if (!any_menu_open) return;
|
|
1039
|
+
function onClickOutside(e: MouseEvent) {
|
|
1040
|
+
const target = e.target as HTMLElement;
|
|
1041
|
+
if (!target.closest('.menu')) {
|
|
1042
|
+
closeAllMenus();
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// Use a timeout to avoid catching the click that opened the menu
|
|
1046
|
+
const timer = setTimeout(() => {
|
|
1047
|
+
document.addEventListener('click', onClickOutside);
|
|
1048
|
+
}, 0);
|
|
1049
|
+
return () => {
|
|
1050
|
+
clearTimeout(timer);
|
|
1051
|
+
document.removeEventListener('click', onClickOutside);
|
|
1052
|
+
};
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// Follow playback at ~60fps so the seek thumb glides instead of stepping on
|
|
1056
|
+
// each (infrequent) `timeupdate`. Pauses while the user scrubs (or a queued
|
|
1057
|
+
// pre-metadata seek is pending) so it never fights the optimistic thumb.
|
|
1058
|
+
$effect(() => {
|
|
1059
|
+
if (!playing || !player || is_scrubbing) return;
|
|
1060
|
+
const vid = player;
|
|
1061
|
+
let raf = 0;
|
|
1062
|
+
let active = true;
|
|
1063
|
+
function tick() {
|
|
1064
|
+
if (!active) return;
|
|
1065
|
+
// Mirror the timeupdate guard: never drive the thumb until the seek
|
|
1066
|
+
// scale is real (duration > 0), or the pre-metadata 0..1 scale spikes
|
|
1067
|
+
// the thumb on first play. Also yield to a queued pre-metadata seek.
|
|
1068
|
+
if (!pending_seek && duration > 0) current_time = vid.currentTime;
|
|
1069
|
+
raf = requestAnimationFrame(tick);
|
|
1070
|
+
}
|
|
1071
|
+
raf = requestAnimationFrame(tick);
|
|
1072
|
+
return () => {
|
|
1073
|
+
active = false;
|
|
1074
|
+
cancelAnimationFrame(raf);
|
|
1075
|
+
};
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
// Volume icon state
|
|
1079
|
+
const volume_icon = $derived(
|
|
1080
|
+
is_muted || volume === 0 ? 'muted' : volume < 0.5 ? 'low' : 'high',
|
|
1081
|
+
);
|
|
1082
|
+
const volume_display = $derived(is_muted ? 0 : Math.round(volume * 100));
|
|
1083
|
+
</script>
|
|
1084
|
+
|
|
1085
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
1086
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
1087
|
+
<div
|
|
1088
|
+
{id}
|
|
1089
|
+
class={['video', class_name].filter(Boolean).join(' ')}
|
|
1090
|
+
class:is-fullscreen={is_fullscreen}
|
|
1091
|
+
style:aspect-ratio={is_fullscreen ? undefined : aspect_ratio}
|
|
1092
|
+
bind:this={element}
|
|
1093
|
+
onmousemove={resetInactivityTimer}
|
|
1094
|
+
onmouseenter={resetInactivityTimer}
|
|
1095
|
+
ontouchstart={resetInactivityTimer}
|
|
1096
|
+
onkeydown={handleKeydown}
|
|
1097
|
+
tabindex="0"
|
|
1098
|
+
role="group"
|
|
1099
|
+
aria-label="Video player">
|
|
1100
|
+
{#if show_skeleton}
|
|
1101
|
+
<div class="skeleton" aria-hidden="true">
|
|
1102
|
+
<div class="skeleton-shimmer"></div>
|
|
1103
|
+
<div class="skeleton-bar">
|
|
1104
|
+
<span class="sk sk-btn"></span>
|
|
1105
|
+
<span class="sk sk-track"></span>
|
|
1106
|
+
<span class="sk sk-pill"></span>
|
|
1107
|
+
<span class="sk sk-btn"></span>
|
|
1108
|
+
<span class="sk sk-btn"></span>
|
|
1109
|
+
</div>
|
|
1110
|
+
</div>
|
|
1111
|
+
{/if}
|
|
1112
|
+
|
|
1113
|
+
<!-- Video element -->
|
|
1114
|
+
<video
|
|
1115
|
+
bind:this={player}
|
|
1116
|
+
{poster}
|
|
1117
|
+
{autoplay}
|
|
1118
|
+
{loop}
|
|
1119
|
+
{preload}
|
|
1120
|
+
muted={is_muted}
|
|
1121
|
+
playsinline
|
|
1122
|
+
crossorigin="anonymous"
|
|
1123
|
+
onclick={togglePlay}
|
|
1124
|
+
onplay={handleVideoPlay}
|
|
1125
|
+
onpause={handleVideoPause}
|
|
1126
|
+
onended={handleVideoEnded}
|
|
1127
|
+
ontimeupdate={handleVideoTimeUpdate}
|
|
1128
|
+
ondurationchange={handleVideoDurationChange}
|
|
1129
|
+
onloadedmetadata={handleVideoLoadedMetadata}
|
|
1130
|
+
onprogress={handleVideoProgress}
|
|
1131
|
+
onvolumechange={handleVideoVolumeChange}
|
|
1132
|
+
onerror={handleVideoError}>
|
|
1133
|
+
{#if !has_src}
|
|
1134
|
+
<!-- No source yet (skeleton / async) — nothing to load -->
|
|
1135
|
+
{:else if is_hls_source}
|
|
1136
|
+
<!-- HLS is attached programmatically (native or via hls.js) in an effect -->
|
|
1137
|
+
{:else if has_quality_options}
|
|
1138
|
+
<source src={active_source.src} type={active_source.type} />
|
|
1139
|
+
{:else}
|
|
1140
|
+
{#each sources as source}
|
|
1141
|
+
<source src={source.src} type={source.type} />
|
|
1142
|
+
{/each}
|
|
1143
|
+
{/if}
|
|
1144
|
+
{#each captions as track}
|
|
1145
|
+
<track
|
|
1146
|
+
kind={track.kind}
|
|
1147
|
+
src={track.src}
|
|
1148
|
+
srclang={track.srclang}
|
|
1149
|
+
label={track.label}
|
|
1150
|
+
default={track.default} />
|
|
1151
|
+
{/each}
|
|
1152
|
+
</video>
|
|
1153
|
+
|
|
1154
|
+
<!-- Big play button overlay (skip when autoplay+muted will start on its own) -->
|
|
1155
|
+
{#if !has_started && !playing && !show_skeleton && !(autoplay && is_muted)}
|
|
1156
|
+
<button
|
|
1157
|
+
class="big-play"
|
|
1158
|
+
type="button"
|
|
1159
|
+
aria-label="Play video"
|
|
1160
|
+
onclick={togglePlay}
|
|
1161
|
+
{@attach ripple({})}>
|
|
1162
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
1163
|
+
<path
|
|
1164
|
+
d="M8.5 5.4a.8.8 0 0 1 1.2-.7l9 6.6a.8.8 0 0 1 0 1.3l-9 6.6a.8.8 0 0 1-1.2-.7V5.4z" />
|
|
1165
|
+
</svg>
|
|
1166
|
+
</button>
|
|
1167
|
+
{/if}
|
|
1168
|
+
|
|
1169
|
+
<!-- Error overlay -->
|
|
1170
|
+
{#if has_error}
|
|
1171
|
+
<div class="error">
|
|
1172
|
+
<svg
|
|
1173
|
+
viewBox="0 0 24 24"
|
|
1174
|
+
fill="none"
|
|
1175
|
+
stroke="currentColor"
|
|
1176
|
+
stroke-width="1.5"
|
|
1177
|
+
stroke-linecap="round"
|
|
1178
|
+
stroke-linejoin="round"
|
|
1179
|
+
aria-hidden="true">
|
|
1180
|
+
<circle cx="12" cy="12" r="10" />
|
|
1181
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
1182
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
1183
|
+
</svg>
|
|
1184
|
+
<span>Video could not be loaded</span>
|
|
1185
|
+
</div>
|
|
1186
|
+
{/if}
|
|
1187
|
+
|
|
1188
|
+
<!-- Custom controls -->
|
|
1189
|
+
{#if controls && !show_skeleton}
|
|
1190
|
+
<div
|
|
1191
|
+
class="controls"
|
|
1192
|
+
class:visible={show_controls || !playing}
|
|
1193
|
+
class:hidden={!show_controls && playing}>
|
|
1194
|
+
<div class="control-bar">
|
|
1195
|
+
<!-- Play / pause (always visible) -->
|
|
1196
|
+
<button
|
|
1197
|
+
class="btn"
|
|
1198
|
+
type="button"
|
|
1199
|
+
aria-label={playing ? 'Pause' : 'Play'}
|
|
1200
|
+
onclick={togglePlay}
|
|
1201
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1202
|
+
{#if playing}
|
|
1203
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
1204
|
+
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
1205
|
+
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
1206
|
+
</svg>
|
|
1207
|
+
{:else}
|
|
1208
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
1209
|
+
<path d="M8 5v14l11-7z" />
|
|
1210
|
+
</svg>
|
|
1211
|
+
{/if}
|
|
1212
|
+
</button>
|
|
1213
|
+
|
|
1214
|
+
<!-- Seek track (always visible, flexes) -->
|
|
1215
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
1216
|
+
<div
|
|
1217
|
+
class="seek"
|
|
1218
|
+
bind:this={seek_el}
|
|
1219
|
+
onpointermove={updateSeekHover}
|
|
1220
|
+
onpointerenter={() => (show_seek_tooltip = true)}
|
|
1221
|
+
onpointerleave={() => (show_seek_tooltip = false)}>
|
|
1222
|
+
<span class="seek-base"></span>
|
|
1223
|
+
<span class="seek-buffered" style:width="{buffered_percent}%"></span>
|
|
1224
|
+
<Range
|
|
1225
|
+
class="v-range seek-range"
|
|
1226
|
+
aria_label="Seek"
|
|
1227
|
+
value={current_time}
|
|
1228
|
+
min={0}
|
|
1229
|
+
max={seek_max}
|
|
1230
|
+
step={seek_step}
|
|
1231
|
+
oninput={(d) => onSeekInput(d.value as number)}
|
|
1232
|
+
onchange={(d) => onSeekCommit(d.value as number)} />
|
|
1233
|
+
|
|
1234
|
+
{#if show_seek_tooltip && duration > 0}
|
|
1235
|
+
<div class="seek-tooltip" style:left="{seek_hover_x}px">
|
|
1236
|
+
{#if active_thumb}
|
|
1237
|
+
{#if active_thumb.xywh}
|
|
1238
|
+
<div
|
|
1239
|
+
class="seek-thumb"
|
|
1240
|
+
style:width="{active_thumb.xywh[2]}px"
|
|
1241
|
+
style:height="{active_thumb.xywh[3]}px"
|
|
1242
|
+
style:background-image="url('{active_thumb.src}')"
|
|
1243
|
+
style:background-position="-{active_thumb.xywh[0]}px -{active_thumb
|
|
1244
|
+
.xywh[1]}px"
|
|
1245
|
+
aria-hidden="true">
|
|
1246
|
+
</div>
|
|
1247
|
+
{:else}
|
|
1248
|
+
<img
|
|
1249
|
+
class="seek-thumb-img"
|
|
1250
|
+
src={active_thumb.src}
|
|
1251
|
+
alt=""
|
|
1252
|
+
aria-hidden="true" />
|
|
1253
|
+
{/if}
|
|
1254
|
+
{/if}
|
|
1255
|
+
<span class="seek-time">{formatTime(seek_hover_time)}</span>
|
|
1256
|
+
</div>
|
|
1257
|
+
{/if}
|
|
1258
|
+
</div>
|
|
1259
|
+
|
|
1260
|
+
<!-- Time (high priority — hidden last) -->
|
|
1261
|
+
<span class="time">
|
|
1262
|
+
{formatTime(current_time)} / {formatTime(duration)}
|
|
1263
|
+
</span>
|
|
1264
|
+
|
|
1265
|
+
<!-- Volume — slider pops up *above* the button so the button never
|
|
1266
|
+
shifts on hover (which used to make it easy to mis-click). -->
|
|
1267
|
+
<div class="ctl volume-group rank-{ranks.volume}">
|
|
1268
|
+
<div class="volume-pop">
|
|
1269
|
+
<Range
|
|
1270
|
+
vertical
|
|
1271
|
+
class="v-range vol-range"
|
|
1272
|
+
aria_label="Volume"
|
|
1273
|
+
value={volume_display}
|
|
1274
|
+
min={0}
|
|
1275
|
+
max={100}
|
|
1276
|
+
step={1}
|
|
1277
|
+
oninput={(d) => onVolumeInput(d.value as number)} />
|
|
1278
|
+
</div>
|
|
1279
|
+
<button
|
|
1280
|
+
class="btn"
|
|
1281
|
+
type="button"
|
|
1282
|
+
aria-label={is_muted ? 'Unmute' : 'Mute'}
|
|
1283
|
+
onclick={toggleMute}
|
|
1284
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1285
|
+
{@render volumeGlyph(volume_icon)}
|
|
1286
|
+
</button>
|
|
1287
|
+
</div>
|
|
1288
|
+
|
|
1289
|
+
<!-- Quality selector -->
|
|
1290
|
+
{#if has_quality}
|
|
1291
|
+
<div class="ctl menu rank-{ranks.quality}">
|
|
1292
|
+
<button
|
|
1293
|
+
class="btn btn-text"
|
|
1294
|
+
type="button"
|
|
1295
|
+
aria-label="Video quality"
|
|
1296
|
+
aria-haspopup="true"
|
|
1297
|
+
aria-expanded={quality_open}
|
|
1298
|
+
onclick={() => {
|
|
1299
|
+
quality_open = !quality_open;
|
|
1300
|
+
speed_open = false;
|
|
1301
|
+
settings_open = false;
|
|
1302
|
+
}}
|
|
1303
|
+
onkeydown={(e) => onSelectorKeydown('quality', e)}
|
|
1304
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1305
|
+
{has_quality_options ? active_quality_label : hls_quality_label}
|
|
1306
|
+
</button>
|
|
1307
|
+
{#if quality_open}
|
|
1308
|
+
<div
|
|
1309
|
+
class="dropdown"
|
|
1310
|
+
role="menu"
|
|
1311
|
+
transition:scale={{ duration: 150, start: 0.92, easing: backOut }}>
|
|
1312
|
+
{#if has_quality_options}
|
|
1313
|
+
{#each quality_sources as source, i}
|
|
1314
|
+
<button
|
|
1315
|
+
class="dropdown-item"
|
|
1316
|
+
class:active={active_source_index === i}
|
|
1317
|
+
type="button"
|
|
1318
|
+
role="menuitem"
|
|
1319
|
+
onclick={() => selectQuality(i)}
|
|
1320
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1321
|
+
{source.size}p
|
|
1322
|
+
</button>
|
|
1323
|
+
{/each}
|
|
1324
|
+
{:else}
|
|
1325
|
+
<button
|
|
1326
|
+
class="dropdown-item"
|
|
1327
|
+
class:active={hls_current_level === -1}
|
|
1328
|
+
type="button"
|
|
1329
|
+
role="menuitem"
|
|
1330
|
+
onclick={() => selectHlsLevel(-1)}
|
|
1331
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1332
|
+
Auto
|
|
1333
|
+
</button>
|
|
1334
|
+
{#each hls_levels as level}
|
|
1335
|
+
<button
|
|
1336
|
+
class="dropdown-item"
|
|
1337
|
+
class:active={hls_current_level === level.index}
|
|
1338
|
+
type="button"
|
|
1339
|
+
role="menuitem"
|
|
1340
|
+
onclick={() => selectHlsLevel(level.index)}
|
|
1341
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1342
|
+
{level.height}p
|
|
1343
|
+
</button>
|
|
1344
|
+
{/each}
|
|
1345
|
+
{/if}
|
|
1346
|
+
</div>
|
|
1347
|
+
{/if}
|
|
1348
|
+
</div>
|
|
1349
|
+
{/if}
|
|
1350
|
+
|
|
1351
|
+
<!-- Playback speed -->
|
|
1352
|
+
<div class="ctl menu rank-{ranks.speed}">
|
|
1353
|
+
<button
|
|
1354
|
+
class="btn btn-text"
|
|
1355
|
+
type="button"
|
|
1356
|
+
aria-label="Playback speed"
|
|
1357
|
+
aria-haspopup="true"
|
|
1358
|
+
aria-expanded={speed_open}
|
|
1359
|
+
onclick={() => {
|
|
1360
|
+
speed_open = !speed_open;
|
|
1361
|
+
quality_open = false;
|
|
1362
|
+
settings_open = false;
|
|
1363
|
+
}}
|
|
1364
|
+
onkeydown={(e) => onSelectorKeydown('speed', e)}
|
|
1365
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1366
|
+
{playback_rate === 1 ? '1x' : `${playback_rate}x`}
|
|
1367
|
+
</button>
|
|
1368
|
+
{#if speed_open}
|
|
1369
|
+
<div
|
|
1370
|
+
class="dropdown"
|
|
1371
|
+
role="menu"
|
|
1372
|
+
transition:scale={{ duration: 150, start: 0.92, easing: backOut }}>
|
|
1373
|
+
{#each SPEEDS as speed}
|
|
1374
|
+
<button
|
|
1375
|
+
class="dropdown-item"
|
|
1376
|
+
class:active={playback_rate === speed}
|
|
1377
|
+
type="button"
|
|
1378
|
+
role="menuitem"
|
|
1379
|
+
onclick={() => selectSpeed(speed)}
|
|
1380
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1381
|
+
{speed}x
|
|
1382
|
+
</button>
|
|
1383
|
+
{/each}
|
|
1384
|
+
</div>
|
|
1385
|
+
{/if}
|
|
1386
|
+
</div>
|
|
1387
|
+
|
|
1388
|
+
<!-- Captions toggle -->
|
|
1389
|
+
{#if captions.length > 0}
|
|
1390
|
+
<button
|
|
1391
|
+
class="ctl btn rank-{ranks.captions}"
|
|
1392
|
+
class:active={captions_active}
|
|
1393
|
+
type="button"
|
|
1394
|
+
aria-pressed={captions_active}
|
|
1395
|
+
aria-label={captions_active ? 'Disable captions' : 'Enable captions'}
|
|
1396
|
+
onclick={toggleCaptions}
|
|
1397
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1398
|
+
{@render captionsGlyph()}
|
|
1399
|
+
</button>
|
|
1400
|
+
{/if}
|
|
1401
|
+
|
|
1402
|
+
<!-- Picture-in-picture -->
|
|
1403
|
+
{#if pip_supported}
|
|
1404
|
+
<button
|
|
1405
|
+
class="ctl btn rank-{ranks.pip}"
|
|
1406
|
+
class:active={is_pip}
|
|
1407
|
+
type="button"
|
|
1408
|
+
aria-pressed={is_pip}
|
|
1409
|
+
aria-label={is_pip ? 'Exit picture-in-picture' : 'Picture-in-picture'}
|
|
1410
|
+
onclick={togglePip}
|
|
1411
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1412
|
+
{@render pipGlyph()}
|
|
1413
|
+
</button>
|
|
1414
|
+
{/if}
|
|
1415
|
+
|
|
1416
|
+
<!-- Settings (gear) — appears only when ≥2 controls have collapsed -->
|
|
1417
|
+
<div class="menu settings-menu">
|
|
1418
|
+
<button
|
|
1419
|
+
class="btn settings-btn"
|
|
1420
|
+
class:open={settings_open}
|
|
1421
|
+
type="button"
|
|
1422
|
+
bind:this={settings_btn}
|
|
1423
|
+
aria-label="Settings"
|
|
1424
|
+
aria-haspopup="true"
|
|
1425
|
+
aria-expanded={settings_open}
|
|
1426
|
+
onclick={toggleSettings}
|
|
1427
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1428
|
+
<svg
|
|
1429
|
+
viewBox="0 0 24 24"
|
|
1430
|
+
fill="none"
|
|
1431
|
+
stroke="currentColor"
|
|
1432
|
+
stroke-width="2"
|
|
1433
|
+
stroke-linecap="round"
|
|
1434
|
+
stroke-linejoin="round"
|
|
1435
|
+
aria-hidden="true">
|
|
1436
|
+
<circle cx="12" cy="12" r="3" />
|
|
1437
|
+
<path
|
|
1438
|
+
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
1439
|
+
</svg>
|
|
1440
|
+
</button>
|
|
1441
|
+
{#if settings_open}
|
|
1442
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
1443
|
+
<div
|
|
1444
|
+
class="dropdown settings-pop"
|
|
1445
|
+
role="menu"
|
|
1446
|
+
tabindex="-1"
|
|
1447
|
+
bind:this={settings_pop}
|
|
1448
|
+
onkeydown={handleSettingsKeydown}
|
|
1449
|
+
transition:scale={{ duration: 160, start: 0.92, easing: backOut }}>
|
|
1450
|
+
<!-- Volume row -->
|
|
1451
|
+
<div class="pop-row rank-{ranks.volume}">
|
|
1452
|
+
<button
|
|
1453
|
+
class="pop-icon"
|
|
1454
|
+
type="button"
|
|
1455
|
+
data-pop-focusable
|
|
1456
|
+
aria-label={is_muted ? 'Unmute' : 'Mute'}
|
|
1457
|
+
onclick={toggleMute}
|
|
1458
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1459
|
+
{@render volumeGlyph(volume_icon)}
|
|
1460
|
+
</button>
|
|
1461
|
+
<div class="pop-slider">
|
|
1462
|
+
<Range
|
|
1463
|
+
class="v-range vol-range"
|
|
1464
|
+
aria_label="Volume"
|
|
1465
|
+
value={volume_display}
|
|
1466
|
+
min={0}
|
|
1467
|
+
max={100}
|
|
1468
|
+
step={1}
|
|
1469
|
+
oninput={(d) => onVolumeInput(d.value as number)} />
|
|
1470
|
+
</div>
|
|
1471
|
+
</div>
|
|
1472
|
+
|
|
1473
|
+
<!-- Quality row -->
|
|
1474
|
+
{#if has_quality}
|
|
1475
|
+
<div class="pop-row pop-group rank-{ranks.quality}">
|
|
1476
|
+
<span class="pop-label">Quality</span>
|
|
1477
|
+
<div class="pop-options">
|
|
1478
|
+
{#if has_quality_options}
|
|
1479
|
+
{#each quality_sources as source, i}
|
|
1480
|
+
<button
|
|
1481
|
+
class="pop-opt"
|
|
1482
|
+
class:active={active_source_index === i}
|
|
1483
|
+
type="button"
|
|
1484
|
+
data-pop-focusable
|
|
1485
|
+
onclick={() => selectQuality(i)}
|
|
1486
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1487
|
+
{source.size}p
|
|
1488
|
+
</button>
|
|
1489
|
+
{/each}
|
|
1490
|
+
{:else}
|
|
1491
|
+
<button
|
|
1492
|
+
class="pop-opt"
|
|
1493
|
+
class:active={hls_current_level === -1}
|
|
1494
|
+
type="button"
|
|
1495
|
+
data-pop-focusable
|
|
1496
|
+
onclick={() => selectHlsLevel(-1)}
|
|
1497
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1498
|
+
Auto
|
|
1499
|
+
</button>
|
|
1500
|
+
{#each hls_levels as level}
|
|
1501
|
+
<button
|
|
1502
|
+
class="pop-opt"
|
|
1503
|
+
class:active={hls_current_level === level.index}
|
|
1504
|
+
type="button"
|
|
1505
|
+
data-pop-focusable
|
|
1506
|
+
onclick={() => selectHlsLevel(level.index)}
|
|
1507
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1508
|
+
{level.height}p
|
|
1509
|
+
</button>
|
|
1510
|
+
{/each}
|
|
1511
|
+
{/if}
|
|
1512
|
+
</div>
|
|
1513
|
+
</div>
|
|
1514
|
+
{/if}
|
|
1515
|
+
|
|
1516
|
+
<!-- Speed row -->
|
|
1517
|
+
<div class="pop-row pop-group rank-{ranks.speed}">
|
|
1518
|
+
<span class="pop-label">Speed</span>
|
|
1519
|
+
<div class="pop-options">
|
|
1520
|
+
{#each SPEEDS as speed}
|
|
1521
|
+
<button
|
|
1522
|
+
class="pop-opt"
|
|
1523
|
+
class:active={playback_rate === speed}
|
|
1524
|
+
type="button"
|
|
1525
|
+
data-pop-focusable
|
|
1526
|
+
onclick={() => selectSpeed(speed)}
|
|
1527
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1528
|
+
{speed}x
|
|
1529
|
+
</button>
|
|
1530
|
+
{/each}
|
|
1531
|
+
</div>
|
|
1532
|
+
</div>
|
|
1533
|
+
|
|
1534
|
+
<!-- Captions row -->
|
|
1535
|
+
{#if captions.length > 0}
|
|
1536
|
+
<button
|
|
1537
|
+
class="pop-row pop-toggle rank-{ranks.captions}"
|
|
1538
|
+
class:active={captions_active}
|
|
1539
|
+
type="button"
|
|
1540
|
+
data-pop-focusable
|
|
1541
|
+
aria-pressed={captions_active}
|
|
1542
|
+
onclick={toggleCaptions}
|
|
1543
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1544
|
+
{@render captionsGlyph()}
|
|
1545
|
+
<span class="pop-text">Captions</span>
|
|
1546
|
+
<span class="pop-state">{captions_active ? 'On' : 'Off'}</span>
|
|
1547
|
+
</button>
|
|
1548
|
+
{/if}
|
|
1549
|
+
|
|
1550
|
+
<!-- PiP row -->
|
|
1551
|
+
{#if pip_supported}
|
|
1552
|
+
<button
|
|
1553
|
+
class="pop-row pop-toggle rank-{ranks.pip}"
|
|
1554
|
+
class:active={is_pip}
|
|
1555
|
+
type="button"
|
|
1556
|
+
data-pop-focusable
|
|
1557
|
+
aria-pressed={is_pip}
|
|
1558
|
+
onclick={togglePip}
|
|
1559
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1560
|
+
{@render pipGlyph()}
|
|
1561
|
+
<span class="pop-text">Picture in picture</span>
|
|
1562
|
+
</button>
|
|
1563
|
+
{/if}
|
|
1564
|
+
|
|
1565
|
+
<!-- Fullscreen row -->
|
|
1566
|
+
<button
|
|
1567
|
+
class="pop-row pop-toggle rank-{ranks.fullscreen}"
|
|
1568
|
+
type="button"
|
|
1569
|
+
data-pop-focusable
|
|
1570
|
+
onclick={toggleFullscreen}
|
|
1571
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1572
|
+
{@render fullscreenGlyph(is_fullscreen)}
|
|
1573
|
+
<span class="pop-text">
|
|
1574
|
+
{is_fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
1575
|
+
</span>
|
|
1576
|
+
</button>
|
|
1577
|
+
</div>
|
|
1578
|
+
{/if}
|
|
1579
|
+
</div>
|
|
1580
|
+
|
|
1581
|
+
<!-- Fullscreen (always far right until it collapses) -->
|
|
1582
|
+
<button
|
|
1583
|
+
class="ctl btn rank-{ranks.fullscreen}"
|
|
1584
|
+
type="button"
|
|
1585
|
+
aria-label={is_fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
1586
|
+
onclick={toggleFullscreen}
|
|
1587
|
+
{@attach ripple({ color: '#fff', opacity: 0.18 })}>
|
|
1588
|
+
{@render fullscreenGlyph(is_fullscreen)}
|
|
1589
|
+
</button>
|
|
1590
|
+
</div>
|
|
1591
|
+
</div>
|
|
1592
|
+
{/if}
|
|
1593
|
+
</div>
|
|
1594
|
+
|
|
1595
|
+
{#snippet volumeGlyph(state: string)}
|
|
1596
|
+
{#if state === 'muted'}
|
|
1597
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
1598
|
+
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
|
1599
|
+
<line
|
|
1600
|
+
x1="23"
|
|
1601
|
+
y1="9"
|
|
1602
|
+
x2="17"
|
|
1603
|
+
y2="15"
|
|
1604
|
+
stroke="currentColor"
|
|
1605
|
+
stroke-width="2"
|
|
1606
|
+
stroke-linecap="round"
|
|
1607
|
+
fill="none" />
|
|
1608
|
+
<line
|
|
1609
|
+
x1="17"
|
|
1610
|
+
y1="9"
|
|
1611
|
+
x2="23"
|
|
1612
|
+
y2="15"
|
|
1613
|
+
stroke="currentColor"
|
|
1614
|
+
stroke-width="2"
|
|
1615
|
+
stroke-linecap="round"
|
|
1616
|
+
fill="none" />
|
|
1617
|
+
</svg>
|
|
1618
|
+
{:else if state === 'low'}
|
|
1619
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
1620
|
+
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
|
1621
|
+
<path
|
|
1622
|
+
d="M15.54 8.46a5 5 0 010 7.07"
|
|
1623
|
+
stroke="currentColor"
|
|
1624
|
+
stroke-width="2"
|
|
1625
|
+
stroke-linecap="round"
|
|
1626
|
+
fill="none" />
|
|
1627
|
+
</svg>
|
|
1628
|
+
{:else}
|
|
1629
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
1630
|
+
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
|
1631
|
+
<path
|
|
1632
|
+
d="M15.54 8.46a5 5 0 010 7.07"
|
|
1633
|
+
stroke="currentColor"
|
|
1634
|
+
stroke-width="2"
|
|
1635
|
+
stroke-linecap="round"
|
|
1636
|
+
fill="none" />
|
|
1637
|
+
<path
|
|
1638
|
+
d="M19.07 4.93a10 10 0 010 14.14"
|
|
1639
|
+
stroke="currentColor"
|
|
1640
|
+
stroke-width="2"
|
|
1641
|
+
stroke-linecap="round"
|
|
1642
|
+
fill="none" />
|
|
1643
|
+
</svg>
|
|
1644
|
+
{/if}
|
|
1645
|
+
{/snippet}
|
|
1646
|
+
|
|
1647
|
+
{#snippet captionsGlyph()}
|
|
1648
|
+
<svg
|
|
1649
|
+
viewBox="0 0 24 24"
|
|
1650
|
+
fill="none"
|
|
1651
|
+
stroke="currentColor"
|
|
1652
|
+
stroke-width="2"
|
|
1653
|
+
stroke-linecap="round"
|
|
1654
|
+
stroke-linejoin="round"
|
|
1655
|
+
aria-hidden="true">
|
|
1656
|
+
<rect x="2" y="4" width="20" height="16" rx="2" />
|
|
1657
|
+
<path d="M7 12h2" />
|
|
1658
|
+
<path d="M15 12h2" />
|
|
1659
|
+
<path d="M7 16h10" />
|
|
1660
|
+
</svg>
|
|
1661
|
+
{/snippet}
|
|
1662
|
+
|
|
1663
|
+
{#snippet pipGlyph()}
|
|
1664
|
+
<svg
|
|
1665
|
+
viewBox="0 0 24 24"
|
|
1666
|
+
fill="none"
|
|
1667
|
+
stroke="currentColor"
|
|
1668
|
+
stroke-width="2"
|
|
1669
|
+
stroke-linecap="round"
|
|
1670
|
+
stroke-linejoin="round"
|
|
1671
|
+
aria-hidden="true">
|
|
1672
|
+
<rect x="2" y="3" width="20" height="14" rx="2" />
|
|
1673
|
+
<rect x="12" y="9" width="8" height="6" rx="1" fill="currentColor" />
|
|
1674
|
+
</svg>
|
|
1675
|
+
{/snippet}
|
|
1676
|
+
|
|
1677
|
+
{#snippet fullscreenGlyph(active: boolean)}
|
|
1678
|
+
{#if active}
|
|
1679
|
+
<svg
|
|
1680
|
+
viewBox="0 0 24 24"
|
|
1681
|
+
fill="none"
|
|
1682
|
+
stroke="currentColor"
|
|
1683
|
+
stroke-width="2"
|
|
1684
|
+
stroke-linecap="round"
|
|
1685
|
+
stroke-linejoin="round"
|
|
1686
|
+
aria-hidden="true">
|
|
1687
|
+
<path d="M8 3v3a2 2 0 01-2 2H3" />
|
|
1688
|
+
<path d="M21 8h-3a2 2 0 01-2-2V3" />
|
|
1689
|
+
<path d="M3 16h3a2 2 0 012 2v3" />
|
|
1690
|
+
<path d="M16 21v-3a2 2 0 012-2h3" />
|
|
1691
|
+
</svg>
|
|
1692
|
+
{:else}
|
|
1693
|
+
<svg
|
|
1694
|
+
viewBox="0 0 24 24"
|
|
1695
|
+
fill="none"
|
|
1696
|
+
stroke="currentColor"
|
|
1697
|
+
stroke-width="2"
|
|
1698
|
+
stroke-linecap="round"
|
|
1699
|
+
stroke-linejoin="round"
|
|
1700
|
+
aria-hidden="true">
|
|
1701
|
+
<path d="M8 3H5a2 2 0 00-2 2v3" />
|
|
1702
|
+
<path d="M21 8V5a2 2 0 00-2-2h-3" />
|
|
1703
|
+
<path d="M3 16v3a2 2 0 002 2h3" />
|
|
1704
|
+
<path d="M16 21h3a2 2 0 002-2v-3" />
|
|
1705
|
+
</svg>
|
|
1706
|
+
{/if}
|
|
1707
|
+
{/snippet}
|
|
1708
|
+
|
|
1709
|
+
<style>
|
|
1710
|
+
.video {
|
|
1711
|
+
position: relative;
|
|
1712
|
+
overflow: hidden;
|
|
1713
|
+
background: black;
|
|
1714
|
+
border-radius: var(--radius-lg, 8px);
|
|
1715
|
+
@supports (corner-shape: squircle) {
|
|
1716
|
+
corner-shape: squircle;
|
|
1717
|
+
border-radius: calc(var(--radius-lg, 8px) * var(--squircle-ratio, 2));
|
|
1718
|
+
}
|
|
1719
|
+
width: 100%;
|
|
1720
|
+
outline: none;
|
|
1721
|
+
font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
|
|
1722
|
+
user-select: none;
|
|
1723
|
+
-webkit-user-select: none;
|
|
1724
|
+
/* Establish a query container so the controls can collapse responsively
|
|
1725
|
+
* with pure CSS (SSR-safe, no hydration flash). */
|
|
1726
|
+
container: dsvideo / inline-size;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
.video:focus-visible {
|
|
1730
|
+
outline: 2px solid rgba(255, 255, 255, 0.8);
|
|
1731
|
+
outline-offset: -2px;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
.video.is-fullscreen {
|
|
1735
|
+
border-radius: 0;
|
|
1736
|
+
width: 100%;
|
|
1737
|
+
height: 100%;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/* ---------- Skeleton (no source known yet) ---------- */
|
|
1741
|
+
.skeleton {
|
|
1742
|
+
position: absolute;
|
|
1743
|
+
inset: 0;
|
|
1744
|
+
z-index: 20;
|
|
1745
|
+
overflow: hidden;
|
|
1746
|
+
/* Always-dark player chrome (like the real controls), regardless of the
|
|
1747
|
+
page theme — the white control pills and sheen read on it in both
|
|
1748
|
+
color schemes. Override with --video-skeleton-bg. */
|
|
1749
|
+
background: var(--video-skeleton-bg, oklch(0.24 0.01 260));
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/* The beam sweeps above the fake control bar (z-index 1) so it reads as
|
|
1753
|
+
glare washing over the whole player surface. The player chrome is
|
|
1754
|
+
always dark, so the sheen stays white rather than theme-aware. */
|
|
1755
|
+
.skeleton-shimmer {
|
|
1756
|
+
position: absolute;
|
|
1757
|
+
inset: 0;
|
|
1758
|
+
z-index: 1;
|
|
1759
|
+
transform: translateX(-100%);
|
|
1760
|
+
background: linear-gradient(
|
|
1761
|
+
105deg,
|
|
1762
|
+
transparent 25%,
|
|
1763
|
+
rgba(255, 255, 255, 0.1) 50%,
|
|
1764
|
+
transparent 75%
|
|
1765
|
+
);
|
|
1766
|
+
animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
|
|
1767
|
+
infinite;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
@keyframes -global-delight-skeleton-shimmer {
|
|
1771
|
+
0% {
|
|
1772
|
+
transform: translateX(-100%);
|
|
1773
|
+
}
|
|
1774
|
+
55%,
|
|
1775
|
+
100% {
|
|
1776
|
+
transform: translateX(100%);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
.skeleton-bar {
|
|
1781
|
+
position: absolute;
|
|
1782
|
+
left: 0;
|
|
1783
|
+
right: 0;
|
|
1784
|
+
bottom: 0;
|
|
1785
|
+
display: flex;
|
|
1786
|
+
align-items: center;
|
|
1787
|
+
gap: 10px;
|
|
1788
|
+
padding: 14px 14px 16px;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
.sk {
|
|
1792
|
+
display: block;
|
|
1793
|
+
background: rgba(255, 255, 255, 0.3);
|
|
1794
|
+
border-radius: 999px;
|
|
1795
|
+
}
|
|
1796
|
+
.sk-btn {
|
|
1797
|
+
width: 24px;
|
|
1798
|
+
height: 24px;
|
|
1799
|
+
border-radius: 8px;
|
|
1800
|
+
@supports (corner-shape: squircle) {
|
|
1801
|
+
corner-shape: squircle;
|
|
1802
|
+
border-radius: calc(8px * var(--squircle-ratio, 2));
|
|
1803
|
+
}
|
|
1804
|
+
flex-shrink: 0;
|
|
1805
|
+
}
|
|
1806
|
+
.sk-track {
|
|
1807
|
+
flex: 1;
|
|
1808
|
+
height: 6px;
|
|
1809
|
+
}
|
|
1810
|
+
.sk-pill {
|
|
1811
|
+
width: 38px;
|
|
1812
|
+
height: 16px;
|
|
1813
|
+
border-radius: 8px;
|
|
1814
|
+
@supports (corner-shape: squircle) {
|
|
1815
|
+
corner-shape: squircle;
|
|
1816
|
+
border-radius: calc(8px * var(--squircle-ratio, 2));
|
|
1817
|
+
}
|
|
1818
|
+
flex-shrink: 0;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/* ---------- Video element ---------- */
|
|
1822
|
+
video {
|
|
1823
|
+
display: block;
|
|
1824
|
+
width: 100%;
|
|
1825
|
+
height: 100%;
|
|
1826
|
+
object-fit: contain;
|
|
1827
|
+
cursor: pointer;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/* ---------- Big play button ---------- */
|
|
1831
|
+
.big-play {
|
|
1832
|
+
position: absolute;
|
|
1833
|
+
top: 50%;
|
|
1834
|
+
left: 50%;
|
|
1835
|
+
transform: translate(-50%, -50%);
|
|
1836
|
+
z-index: 5;
|
|
1837
|
+
width: 76px;
|
|
1838
|
+
height: 76px;
|
|
1839
|
+
border-radius: var(--radius-full, 50%);
|
|
1840
|
+
background: rgba(0, 0, 0, 0.45);
|
|
1841
|
+
color: white;
|
|
1842
|
+
border: none;
|
|
1843
|
+
overflow: hidden;
|
|
1844
|
+
cursor: pointer;
|
|
1845
|
+
display: flex;
|
|
1846
|
+
align-items: center;
|
|
1847
|
+
justify-content: center;
|
|
1848
|
+
backdrop-filter: blur(14px) saturate(160%);
|
|
1849
|
+
-webkit-backdrop-filter: blur(14px) saturate(160%);
|
|
1850
|
+
transition:
|
|
1851
|
+
transform 150ms var(--ease-out, ease),
|
|
1852
|
+
background 150ms var(--ease-out, ease),
|
|
1853
|
+
width 200ms var(--ease-out, ease),
|
|
1854
|
+
height 200ms var(--ease-out, ease),
|
|
1855
|
+
opacity 150ms var(--ease-out, ease);
|
|
1856
|
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
|
1857
|
+
-webkit-tap-highlight-color: transparent;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
.big-play:hover {
|
|
1861
|
+
transform: translate(-50%, -50%) scale(1.06);
|
|
1862
|
+
background: rgba(0, 0, 0, 0.55);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
.big-play:active {
|
|
1866
|
+
transform: translate(-50%, -50%) scale(0.9);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
.big-play svg {
|
|
1870
|
+
width: 46px;
|
|
1871
|
+
height: 46px;
|
|
1872
|
+
margin-left: -3px;
|
|
1873
|
+
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.25));
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/* ---------- Error overlay ---------- */
|
|
1877
|
+
.error {
|
|
1878
|
+
position: absolute;
|
|
1879
|
+
inset: 0;
|
|
1880
|
+
z-index: 5;
|
|
1881
|
+
display: flex;
|
|
1882
|
+
flex-direction: column;
|
|
1883
|
+
align-items: center;
|
|
1884
|
+
justify-content: center;
|
|
1885
|
+
gap: 8px;
|
|
1886
|
+
background: rgba(0, 0, 0, 0.8);
|
|
1887
|
+
color: rgba(255, 255, 255, 0.7);
|
|
1888
|
+
font-size: var(--text-sm, 0.875rem);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
.error svg {
|
|
1892
|
+
width: 32px;
|
|
1893
|
+
height: 32px;
|
|
1894
|
+
opacity: 0.7;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/* ---------- Controls container ---------- */
|
|
1898
|
+
.controls {
|
|
1899
|
+
position: absolute;
|
|
1900
|
+
bottom: 0;
|
|
1901
|
+
left: 0;
|
|
1902
|
+
right: 0;
|
|
1903
|
+
z-index: 10;
|
|
1904
|
+
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
|
1905
|
+
padding: 40px 0 0;
|
|
1906
|
+
transition: opacity 150ms var(--ease-out, ease);
|
|
1907
|
+
opacity: 0;
|
|
1908
|
+
pointer-events: none;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
.controls.visible {
|
|
1912
|
+
opacity: 1;
|
|
1913
|
+
pointer-events: auto;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
.controls.hidden {
|
|
1917
|
+
opacity: 0;
|
|
1918
|
+
pointer-events: none;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/* ---------- Single-row control bar ---------- */
|
|
1922
|
+
.control-bar {
|
|
1923
|
+
display: flex;
|
|
1924
|
+
align-items: center;
|
|
1925
|
+
gap: 2px;
|
|
1926
|
+
padding: 6px 8px 8px;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
.ctl {
|
|
1930
|
+
flex-shrink: 0;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/* ---------- Buttons (Button-like ripple + active) ---------- */
|
|
1934
|
+
.btn {
|
|
1935
|
+
position: relative;
|
|
1936
|
+
display: flex;
|
|
1937
|
+
align-items: center;
|
|
1938
|
+
justify-content: center;
|
|
1939
|
+
width: 38px;
|
|
1940
|
+
height: 38px;
|
|
1941
|
+
border: none;
|
|
1942
|
+
border-radius: var(--radius-md, 6px);
|
|
1943
|
+
@supports (corner-shape: squircle) {
|
|
1944
|
+
corner-shape: squircle;
|
|
1945
|
+
border-radius: calc(var(--radius-md, 6px) * var(--squircle-ratio, 2));
|
|
1946
|
+
}
|
|
1947
|
+
background: transparent;
|
|
1948
|
+
color: rgba(255, 255, 255, 0.92);
|
|
1949
|
+
cursor: pointer;
|
|
1950
|
+
padding: 0;
|
|
1951
|
+
overflow: hidden;
|
|
1952
|
+
flex-shrink: 0;
|
|
1953
|
+
-webkit-tap-highlight-color: transparent;
|
|
1954
|
+
transition:
|
|
1955
|
+
background 150ms var(--ease-out, ease),
|
|
1956
|
+
color 150ms var(--ease-out, ease),
|
|
1957
|
+
transform 140ms var(--ease-out, ease);
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
.btn:hover {
|
|
1961
|
+
background: rgba(255, 255, 255, 0.14);
|
|
1962
|
+
color: #fff;
|
|
1963
|
+
/* Snap the tint in on hover; the base rule eases it back out on leave. */
|
|
1964
|
+
transition: transform 140ms var(--ease-out, ease);
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
.btn:focus-visible {
|
|
1968
|
+
outline: 2px solid rgba(255, 255, 255, 0.85);
|
|
1969
|
+
outline-offset: -2px;
|
|
1970
|
+
color: #fff;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/* Strong, Button-style press: scale down + push back. The perspective is
|
|
1974
|
+
* applied per-button (transform function, not the parent) so it recedes
|
|
1975
|
+
* toward its own center, not the bar's. */
|
|
1976
|
+
.btn:active {
|
|
1977
|
+
background: rgba(255, 255, 255, 0.22);
|
|
1978
|
+
transform: perspective(220px) translateZ(-16px) scale(0.84);
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/* Toggle/active state stays monochrome (no brand color) */
|
|
1982
|
+
.btn.active {
|
|
1983
|
+
color: #fff;
|
|
1984
|
+
background: rgba(255, 255, 255, 0.18);
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
.btn svg {
|
|
1988
|
+
width: 24px;
|
|
1989
|
+
height: 24px;
|
|
1990
|
+
pointer-events: none;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
.btn-text {
|
|
1994
|
+
width: auto;
|
|
1995
|
+
min-width: 38px;
|
|
1996
|
+
padding: 0 9px;
|
|
1997
|
+
font-size: 0.9rem;
|
|
1998
|
+
font-weight: 600;
|
|
1999
|
+
font-family: inherit;
|
|
2000
|
+
letter-spacing: 0.02em;
|
|
2001
|
+
font-variant-numeric: tabular-nums;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
/* Settings gear spins open */
|
|
2005
|
+
.settings-btn svg {
|
|
2006
|
+
transition: transform 400ms var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
|
|
2007
|
+
}
|
|
2008
|
+
.settings-btn.open svg {
|
|
2009
|
+
transform: rotate(90deg);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/* ---------- Seek track ---------- */
|
|
2013
|
+
.seek {
|
|
2014
|
+
position: relative;
|
|
2015
|
+
flex: 1 1 auto;
|
|
2016
|
+
min-width: 40px;
|
|
2017
|
+
display: flex;
|
|
2018
|
+
align-items: center;
|
|
2019
|
+
height: 24px;
|
|
2020
|
+
margin: 0 6px;
|
|
2021
|
+
touch-action: none;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
.seek-base,
|
|
2025
|
+
.seek-buffered {
|
|
2026
|
+
position: absolute;
|
|
2027
|
+
top: 50%;
|
|
2028
|
+
transform: translateY(-50%);
|
|
2029
|
+
height: 4px;
|
|
2030
|
+
border-radius: 999px;
|
|
2031
|
+
pointer-events: none;
|
|
2032
|
+
transition: height 150ms var(--ease-out, ease);
|
|
2033
|
+
}
|
|
2034
|
+
.seek-base {
|
|
2035
|
+
left: 0;
|
|
2036
|
+
right: 0;
|
|
2037
|
+
background: rgba(255, 255, 255, 0.26);
|
|
2038
|
+
}
|
|
2039
|
+
.seek-buffered {
|
|
2040
|
+
left: 0;
|
|
2041
|
+
background: rgba(255, 255, 255, 0.45);
|
|
2042
|
+
}
|
|
2043
|
+
.seek:hover .seek-base,
|
|
2044
|
+
.seek:hover .seek-buffered {
|
|
2045
|
+
height: 6px;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
/* Range slider override: monochrome white + a slightly shorter handle. Uses
|
|
2049
|
+
* :global so the rule reaches the Range child component's container (scoped
|
|
2050
|
+
* selectors would not). */
|
|
2051
|
+
.video :global(.v-range) {
|
|
2052
|
+
--fill-color: #fff;
|
|
2053
|
+
--track-bg: rgba(255, 255, 255, 0.28);
|
|
2054
|
+
--handle-height: 20px;
|
|
2055
|
+
}
|
|
2056
|
+
.video :global(.v-range input) {
|
|
2057
|
+
-webkit-tap-highlight-color: transparent;
|
|
2058
|
+
}
|
|
2059
|
+
/* Seek-specific: transparent inactive track so the buffered/base layers show
|
|
2060
|
+
* through; sit above them and flex to fill the row. */
|
|
2061
|
+
.seek :global(.v-range) {
|
|
2062
|
+
--track-bg: transparent;
|
|
2063
|
+
position: relative;
|
|
2064
|
+
z-index: 1;
|
|
2065
|
+
flex: 1 1 auto;
|
|
2066
|
+
min-width: 0;
|
|
2067
|
+
}
|
|
2068
|
+
/* The fill is driven at ~60fps by the rAF loop during playback, so drop the
|
|
2069
|
+
* Range's position easing here — otherwise the fill lags ~100ms behind the
|
|
2070
|
+
* pointer/playback position. Keep only the hover height grow. */
|
|
2071
|
+
.seek :global(.track-segment) {
|
|
2072
|
+
transition: height 150ms var(--ease-out, ease);
|
|
2073
|
+
}
|
|
2074
|
+
/* Same for the handle: it follows the raw current time at ~60fps, so drop its
|
|
2075
|
+
* position easing so it stays pinned to the fill edge instead of lagging it.
|
|
2076
|
+
* Keep the hover grow + halo. */
|
|
2077
|
+
.seek :global(.handle) {
|
|
2078
|
+
transition:
|
|
2079
|
+
transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
|
2080
|
+
box-shadow 150ms ease;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
/* ---------- Seek hover tooltip ---------- */
|
|
2084
|
+
.seek-tooltip {
|
|
2085
|
+
position: absolute;
|
|
2086
|
+
bottom: calc(100% + 6px);
|
|
2087
|
+
transform: translateX(-50%);
|
|
2088
|
+
display: flex;
|
|
2089
|
+
flex-direction: column;
|
|
2090
|
+
align-items: center;
|
|
2091
|
+
gap: 4px;
|
|
2092
|
+
background: rgba(0, 0, 0, 0.85);
|
|
2093
|
+
backdrop-filter: blur(8px);
|
|
2094
|
+
-webkit-backdrop-filter: blur(8px);
|
|
2095
|
+
color: white;
|
|
2096
|
+
padding: 4px;
|
|
2097
|
+
border-radius: var(--radius-md, 4px);
|
|
2098
|
+
@supports (corner-shape: squircle) {
|
|
2099
|
+
corner-shape: squircle;
|
|
2100
|
+
border-radius: calc(var(--radius-md, 4px) * var(--squircle-ratio, 2));
|
|
2101
|
+
}
|
|
2102
|
+
font-size: var(--text-xs, 0.75rem);
|
|
2103
|
+
white-space: nowrap;
|
|
2104
|
+
pointer-events: none;
|
|
2105
|
+
font-variant-numeric: tabular-nums;
|
|
2106
|
+
z-index: 3;
|
|
2107
|
+
}
|
|
2108
|
+
.seek-thumb,
|
|
2109
|
+
.seek-thumb-img {
|
|
2110
|
+
display: block;
|
|
2111
|
+
max-width: 200px;
|
|
2112
|
+
max-height: 120px;
|
|
2113
|
+
background-repeat: no-repeat;
|
|
2114
|
+
border-radius: 2px;
|
|
2115
|
+
}
|
|
2116
|
+
.seek-time {
|
|
2117
|
+
padding: 0 4px;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/* ---------- Time ---------- */
|
|
2121
|
+
.time {
|
|
2122
|
+
color: rgba(255, 255, 255, 0.92);
|
|
2123
|
+
font-size: 0.9rem;
|
|
2124
|
+
font-variant-numeric: tabular-nums;
|
|
2125
|
+
white-space: nowrap;
|
|
2126
|
+
padding: 0 6px;
|
|
2127
|
+
flex-shrink: 0;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
/* ---------- Volume ---------- */
|
|
2131
|
+
.volume-group {
|
|
2132
|
+
position: relative;
|
|
2133
|
+
display: flex;
|
|
2134
|
+
align-items: center;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/* Vertical slider floating above the mute button, so the button never shifts
|
|
2138
|
+
* on hover (which previously made it easy to mis-click the track). */
|
|
2139
|
+
.volume-pop {
|
|
2140
|
+
position: absolute;
|
|
2141
|
+
bottom: calc(100% + 6px);
|
|
2142
|
+
left: 50%;
|
|
2143
|
+
display: flex;
|
|
2144
|
+
justify-content: center;
|
|
2145
|
+
padding: 12px 12px;
|
|
2146
|
+
--range-height: 92px;
|
|
2147
|
+
background: rgba(20, 20, 22, 0.62);
|
|
2148
|
+
backdrop-filter: blur(16px) saturate(150%);
|
|
2149
|
+
-webkit-backdrop-filter: blur(16px) saturate(150%);
|
|
2150
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
2151
|
+
border-radius: var(--radius-lg, 10px);
|
|
2152
|
+
@supports (corner-shape: squircle) {
|
|
2153
|
+
corner-shape: squircle;
|
|
2154
|
+
border-radius: calc(var(--radius-lg, 10px) * var(--squircle-ratio, 2));
|
|
2155
|
+
}
|
|
2156
|
+
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
|
2157
|
+
opacity: 0;
|
|
2158
|
+
pointer-events: none;
|
|
2159
|
+
transform: translateX(-50%) translateY(6px) scale(0.96);
|
|
2160
|
+
transform-origin: bottom center;
|
|
2161
|
+
transition:
|
|
2162
|
+
opacity 160ms var(--ease-out, ease),
|
|
2163
|
+
transform 160ms var(--ease-out, ease);
|
|
2164
|
+
z-index: 20;
|
|
2165
|
+
touch-action: none;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/* A vertical Range reserves its full length as layout width; pin it to the
|
|
2169
|
+
* handle thickness and left-align so the popup hugs the slider instead of
|
|
2170
|
+
* being ~92px wide. */
|
|
2171
|
+
.volume-pop :global(.range-container.vertical) {
|
|
2172
|
+
width: 20px;
|
|
2173
|
+
align-items: flex-start;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/* Invisible bridge over the gap to the button so the hover isn't lost when
|
|
2177
|
+
* the pointer travels from the button up to the slider. */
|
|
2178
|
+
.volume-pop::before {
|
|
2179
|
+
content: '';
|
|
2180
|
+
position: absolute;
|
|
2181
|
+
top: 100%;
|
|
2182
|
+
left: 0;
|
|
2183
|
+
right: 0;
|
|
2184
|
+
height: 10px;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
.volume-group:hover .volume-pop,
|
|
2188
|
+
.volume-group:focus-within .volume-pop {
|
|
2189
|
+
opacity: 1;
|
|
2190
|
+
pointer-events: auto;
|
|
2191
|
+
transform: translateX(-50%) translateY(0) scale(1);
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
/* ---------- Menus / dropdowns ---------- */
|
|
2195
|
+
.menu {
|
|
2196
|
+
position: relative;
|
|
2197
|
+
display: flex;
|
|
2198
|
+
align-items: center;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
.dropdown {
|
|
2202
|
+
position: absolute;
|
|
2203
|
+
bottom: calc(100% + 8px);
|
|
2204
|
+
right: 0;
|
|
2205
|
+
transform-origin: bottom right;
|
|
2206
|
+
background: rgba(20, 20, 22, 0.62);
|
|
2207
|
+
backdrop-filter: blur(16px) saturate(150%);
|
|
2208
|
+
-webkit-backdrop-filter: blur(16px) saturate(150%);
|
|
2209
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
2210
|
+
border-radius: var(--radius-lg, 10px);
|
|
2211
|
+
@supports (corner-shape: squircle) {
|
|
2212
|
+
corner-shape: squircle;
|
|
2213
|
+
border-radius: calc(var(--radius-lg, 10px) * var(--squircle-ratio, 2));
|
|
2214
|
+
}
|
|
2215
|
+
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
|
2216
|
+
padding: 5px;
|
|
2217
|
+
min-width: 96px;
|
|
2218
|
+
z-index: 20;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
.dropdown-item {
|
|
2222
|
+
position: relative;
|
|
2223
|
+
display: block;
|
|
2224
|
+
width: 100%;
|
|
2225
|
+
padding: 7px 14px;
|
|
2226
|
+
border: none;
|
|
2227
|
+
border-radius: var(--radius-md, 6px);
|
|
2228
|
+
@supports (corner-shape: squircle) {
|
|
2229
|
+
corner-shape: squircle;
|
|
2230
|
+
border-radius: calc(var(--radius-md, 6px) * var(--squircle-ratio, 2));
|
|
2231
|
+
}
|
|
2232
|
+
background: transparent;
|
|
2233
|
+
color: rgba(255, 255, 255, 0.9);
|
|
2234
|
+
cursor: pointer;
|
|
2235
|
+
overflow: hidden;
|
|
2236
|
+
font-size: var(--text-sm, 0.875rem);
|
|
2237
|
+
font-family: inherit;
|
|
2238
|
+
text-align: left;
|
|
2239
|
+
white-space: nowrap;
|
|
2240
|
+
font-variant-numeric: tabular-nums;
|
|
2241
|
+
-webkit-tap-highlight-color: transparent;
|
|
2242
|
+
transition:
|
|
2243
|
+
background 120ms var(--ease-out, ease),
|
|
2244
|
+
transform 120ms var(--ease-out, ease);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
.dropdown-item:hover,
|
|
2248
|
+
.dropdown-item:focus-visible {
|
|
2249
|
+
background: rgba(255, 255, 255, 0.12);
|
|
2250
|
+
outline: none;
|
|
2251
|
+
/* Snap the tint in on hover; the base rule eases it back out on leave. */
|
|
2252
|
+
transition: transform 120ms var(--ease-out, ease);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
.dropdown-item:active {
|
|
2256
|
+
transform: scale(0.96);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
.dropdown-item.active {
|
|
2260
|
+
color: #fff;
|
|
2261
|
+
font-weight: 700;
|
|
2262
|
+
background: rgba(255, 255, 255, 0.08);
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
/* ---------- Settings popover content ---------- */
|
|
2266
|
+
.settings-menu {
|
|
2267
|
+
/* hidden until the responsive breakpoints reveal it (≥2 collapsed) */
|
|
2268
|
+
display: none;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
.settings-pop {
|
|
2272
|
+
min-width: 240px;
|
|
2273
|
+
max-width: min(320px, 80cqw);
|
|
2274
|
+
display: flex;
|
|
2275
|
+
flex-direction: column;
|
|
2276
|
+
gap: 2px;
|
|
2277
|
+
padding: 8px;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
.pop-row {
|
|
2281
|
+
/* shown per-control by the responsive breakpoints below */
|
|
2282
|
+
display: none;
|
|
2283
|
+
align-items: center;
|
|
2284
|
+
gap: 10px;
|
|
2285
|
+
width: 100%;
|
|
2286
|
+
padding: 6px 8px;
|
|
2287
|
+
border: none;
|
|
2288
|
+
background: transparent;
|
|
2289
|
+
color: rgba(255, 255, 255, 0.92);
|
|
2290
|
+
border-radius: var(--radius-md, 6px);
|
|
2291
|
+
@supports (corner-shape: squircle) {
|
|
2292
|
+
corner-shape: squircle;
|
|
2293
|
+
border-radius: calc(var(--radius-md, 6px) * var(--squircle-ratio, 2));
|
|
2294
|
+
}
|
|
2295
|
+
font-family: inherit;
|
|
2296
|
+
font-size: var(--text-sm, 0.875rem);
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
.pop-row :global(svg) {
|
|
2300
|
+
width: 20px;
|
|
2301
|
+
height: 20px;
|
|
2302
|
+
flex-shrink: 0;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
.pop-icon {
|
|
2306
|
+
position: relative;
|
|
2307
|
+
display: flex;
|
|
2308
|
+
align-items: center;
|
|
2309
|
+
justify-content: center;
|
|
2310
|
+
width: 32px;
|
|
2311
|
+
height: 32px;
|
|
2312
|
+
flex-shrink: 0;
|
|
2313
|
+
border: none;
|
|
2314
|
+
border-radius: var(--radius-md, 6px);
|
|
2315
|
+
@supports (corner-shape: squircle) {
|
|
2316
|
+
corner-shape: squircle;
|
|
2317
|
+
border-radius: calc(var(--radius-md, 6px) * var(--squircle-ratio, 2));
|
|
2318
|
+
}
|
|
2319
|
+
background: transparent;
|
|
2320
|
+
color: inherit;
|
|
2321
|
+
cursor: pointer;
|
|
2322
|
+
padding: 0;
|
|
2323
|
+
overflow: hidden;
|
|
2324
|
+
-webkit-tap-highlight-color: transparent;
|
|
2325
|
+
transition: transform 120ms var(--ease-out, ease);
|
|
2326
|
+
}
|
|
2327
|
+
.pop-icon:hover,
|
|
2328
|
+
.pop-icon:focus-visible {
|
|
2329
|
+
background: rgba(255, 255, 255, 0.12);
|
|
2330
|
+
outline: none;
|
|
2331
|
+
}
|
|
2332
|
+
.pop-icon:active {
|
|
2333
|
+
transform: scale(0.88);
|
|
2334
|
+
}
|
|
2335
|
+
.pop-slider {
|
|
2336
|
+
flex: 1;
|
|
2337
|
+
display: flex;
|
|
2338
|
+
align-items: center;
|
|
2339
|
+
padding-right: 6px;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
.pop-group {
|
|
2343
|
+
flex-direction: column;
|
|
2344
|
+
align-items: stretch;
|
|
2345
|
+
gap: 6px;
|
|
2346
|
+
}
|
|
2347
|
+
.pop-label {
|
|
2348
|
+
font-size: var(--text-xs, 0.72rem);
|
|
2349
|
+
text-transform: uppercase;
|
|
2350
|
+
letter-spacing: 0.06em;
|
|
2351
|
+
color: rgba(255, 255, 255, 0.55);
|
|
2352
|
+
}
|
|
2353
|
+
.pop-options {
|
|
2354
|
+
display: flex;
|
|
2355
|
+
flex-wrap: wrap;
|
|
2356
|
+
gap: 4px;
|
|
2357
|
+
}
|
|
2358
|
+
.pop-opt {
|
|
2359
|
+
position: relative;
|
|
2360
|
+
flex: 0 0 auto;
|
|
2361
|
+
padding: 4px 10px;
|
|
2362
|
+
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
2363
|
+
border-radius: 999px;
|
|
2364
|
+
background: transparent;
|
|
2365
|
+
color: rgba(255, 255, 255, 0.85);
|
|
2366
|
+
cursor: pointer;
|
|
2367
|
+
overflow: hidden;
|
|
2368
|
+
font-size: var(--text-xs, 0.75rem);
|
|
2369
|
+
font-family: inherit;
|
|
2370
|
+
font-variant-numeric: tabular-nums;
|
|
2371
|
+
-webkit-tap-highlight-color: transparent;
|
|
2372
|
+
transition:
|
|
2373
|
+
background 120ms ease,
|
|
2374
|
+
border-color 120ms ease,
|
|
2375
|
+
transform 120ms var(--ease-out, ease);
|
|
2376
|
+
}
|
|
2377
|
+
.pop-opt:hover,
|
|
2378
|
+
.pop-opt:focus-visible {
|
|
2379
|
+
background: rgba(255, 255, 255, 0.12);
|
|
2380
|
+
outline: none;
|
|
2381
|
+
/* Snap the tint in on hover; the base rule eases it back out on leave. */
|
|
2382
|
+
transition: transform 120ms var(--ease-out, ease);
|
|
2383
|
+
}
|
|
2384
|
+
.pop-opt:active {
|
|
2385
|
+
transform: scale(0.9);
|
|
2386
|
+
}
|
|
2387
|
+
.pop-opt.active {
|
|
2388
|
+
background: #fff;
|
|
2389
|
+
border-color: #fff;
|
|
2390
|
+
color: #000;
|
|
2391
|
+
font-weight: 700;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
.pop-toggle {
|
|
2395
|
+
position: relative;
|
|
2396
|
+
cursor: pointer;
|
|
2397
|
+
text-align: left;
|
|
2398
|
+
overflow: hidden;
|
|
2399
|
+
-webkit-tap-highlight-color: transparent;
|
|
2400
|
+
transition:
|
|
2401
|
+
background 120ms var(--ease-out, ease),
|
|
2402
|
+
transform 120ms var(--ease-out, ease);
|
|
2403
|
+
}
|
|
2404
|
+
.pop-toggle:hover,
|
|
2405
|
+
.pop-toggle:focus-visible {
|
|
2406
|
+
background: rgba(255, 255, 255, 0.12);
|
|
2407
|
+
outline: none;
|
|
2408
|
+
/* Snap the tint in on hover; the base rule eases it back out on leave. */
|
|
2409
|
+
transition: transform 120ms var(--ease-out, ease);
|
|
2410
|
+
}
|
|
2411
|
+
.pop-toggle:active {
|
|
2412
|
+
transform: scale(0.97);
|
|
2413
|
+
}
|
|
2414
|
+
.pop-toggle.active {
|
|
2415
|
+
color: #fff;
|
|
2416
|
+
background: rgba(255, 255, 255, 0.16);
|
|
2417
|
+
}
|
|
2418
|
+
.pop-text {
|
|
2419
|
+
flex: 1;
|
|
2420
|
+
}
|
|
2421
|
+
.pop-state {
|
|
2422
|
+
color: rgba(255, 255, 255, 0.55);
|
|
2423
|
+
font-variant-numeric: tabular-nums;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
/* ====================================================================
|
|
2427
|
+
* Responsive collapse — pure CSS container queries keyed to rank class.
|
|
2428
|
+
* Ranks 1 & 2 collapse together (settings popover never holds 1 item) and
|
|
2429
|
+
* the gear appears at the same breakpoint. Time hides last.
|
|
2430
|
+
* ==================================================================== */
|
|
2431
|
+
@container dsvideo (max-width: 520px) {
|
|
2432
|
+
.control-bar .ctl.rank-1,
|
|
2433
|
+
.control-bar .ctl.rank-2 {
|
|
2434
|
+
display: none;
|
|
2435
|
+
}
|
|
2436
|
+
.settings-menu {
|
|
2437
|
+
display: flex;
|
|
2438
|
+
}
|
|
2439
|
+
.settings-pop .pop-row.rank-1,
|
|
2440
|
+
.settings-pop .pop-row.rank-2 {
|
|
2441
|
+
display: flex;
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
@container dsvideo (max-width: 450px) {
|
|
2445
|
+
.control-bar .ctl.rank-3 {
|
|
2446
|
+
display: none;
|
|
2447
|
+
}
|
|
2448
|
+
.settings-pop .pop-row.rank-3 {
|
|
2449
|
+
display: flex;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
@container dsvideo (max-width: 390px) {
|
|
2453
|
+
.control-bar .ctl.rank-4 {
|
|
2454
|
+
display: none;
|
|
2455
|
+
}
|
|
2456
|
+
.settings-pop .pop-row.rank-4 {
|
|
2457
|
+
display: flex;
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
@container dsvideo (max-width: 340px) {
|
|
2461
|
+
.control-bar .ctl.rank-5 {
|
|
2462
|
+
display: none;
|
|
2463
|
+
}
|
|
2464
|
+
.settings-pop .pop-row.rank-5 {
|
|
2465
|
+
display: flex;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
@container dsvideo (max-width: 300px) {
|
|
2469
|
+
.control-bar .ctl.rank-6 {
|
|
2470
|
+
display: none;
|
|
2471
|
+
}
|
|
2472
|
+
.settings-pop .pop-row.rank-6 {
|
|
2473
|
+
display: flex;
|
|
2474
|
+
}
|
|
2475
|
+
/* Shrink the centre play button so it doesn't dominate a tiny player */
|
|
2476
|
+
.big-play {
|
|
2477
|
+
width: 48px;
|
|
2478
|
+
height: 48px;
|
|
2479
|
+
}
|
|
2480
|
+
.big-play svg {
|
|
2481
|
+
width: 30px;
|
|
2482
|
+
height: 30px;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
@container dsvideo (max-width: 250px) {
|
|
2486
|
+
.time {
|
|
2487
|
+
display: none;
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2492
|
+
.skeleton-shimmer {
|
|
2493
|
+
animation: none;
|
|
2494
|
+
}
|
|
2495
|
+
.settings-btn svg,
|
|
2496
|
+
.btn,
|
|
2497
|
+
.big-play {
|
|
2498
|
+
transition: none;
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
</style>
|