@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.
Files changed (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. 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>