@karnstack/kino 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.
@@ -0,0 +1,1513 @@
1
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/util/format-time.ts
4
+ function formatTime(seconds) {
5
+ const s = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0;
6
+ const h = Math.floor(s / 3600);
7
+ const m = Math.floor(s % 3600 / 60);
8
+ const sec = s % 60;
9
+ const pad = (n) => String(n).padStart(2, "0");
10
+ return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
11
+ }
12
+ //#endregion
13
+ //#region src/core/store.tsx
14
+ const PlayerContext = createContext(null);
15
+ function useProvider() {
16
+ const p = useContext(PlayerContext);
17
+ if (!p) throw new Error("kino: components must render inside <Player>");
18
+ return p;
19
+ }
20
+ function useMediaSelector(selector, isEqual = Object.is) {
21
+ const provider = useProvider();
22
+ const cache = useRef({
23
+ has: false,
24
+ value: void 0
25
+ });
26
+ const getSnapshot = () => {
27
+ const next = selector(provider.getState());
28
+ if (cache.current.has && isEqual(cache.current.value, next)) return cache.current.value;
29
+ cache.current = {
30
+ has: true,
31
+ value: next
32
+ };
33
+ return next;
34
+ };
35
+ return useSyncExternalStore(provider.subscribe, getSnapshot, getSnapshot);
36
+ }
37
+ function usePlayer() {
38
+ const provider = useProvider();
39
+ return {
40
+ state: useMediaSelector((s) => s),
41
+ actions: provider.actions
42
+ };
43
+ }
44
+ function usePlayerActions() {
45
+ return useProvider().actions;
46
+ }
47
+ //#endregion
48
+ //#region src/util/keymap.ts
49
+ function isTypingTarget(el) {
50
+ if (!(el instanceof HTMLElement)) return false;
51
+ const tag = el.tagName;
52
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;
53
+ }
54
+ function resolveKey(e) {
55
+ if (e.ctrlKey || e.metaKey || e.altKey) return null;
56
+ switch (e.key) {
57
+ case " ":
58
+ case "k":
59
+ case "MediaPlayPause":
60
+ case "MediaPlay":
61
+ case "MediaPause": return { type: "toggle-play" };
62
+ case "ArrowRight": return {
63
+ type: "seek-by",
64
+ delta: 5
65
+ };
66
+ case "ArrowLeft": return {
67
+ type: "seek-by",
68
+ delta: -5
69
+ };
70
+ case "ArrowUp": return {
71
+ type: "volume-by",
72
+ delta: .1
73
+ };
74
+ case "ArrowDown": return {
75
+ type: "volume-by",
76
+ delta: -.1
77
+ };
78
+ case "f": return { type: "toggle-fullscreen" };
79
+ case "m": return { type: "toggle-mute" };
80
+ case "c": return { type: "toggle-captions" };
81
+ case "s": return { type: "open-speed" };
82
+ case "<": return {
83
+ type: "rate-by",
84
+ delta: -.25
85
+ };
86
+ case ">": return {
87
+ type: "rate-by",
88
+ delta: .25
89
+ };
90
+ }
91
+ if (/^[0-9]$/.test(e.key)) return {
92
+ type: "seek-percent",
93
+ percent: Number(e.key) * 10
94
+ };
95
+ return null;
96
+ }
97
+ //#endregion
98
+ //#region src/ui/icons.tsx
99
+ function Icon({ children, ...props }) {
100
+ return /* @__PURE__ */ jsx("svg", {
101
+ width: "24",
102
+ height: "24",
103
+ viewBox: "0 0 24 24",
104
+ fill: "none",
105
+ "aria-hidden": "true",
106
+ focusable: "false",
107
+ ...props,
108
+ children
109
+ });
110
+ }
111
+ function PlayIcon(props) {
112
+ return /* @__PURE__ */ jsx(Icon, {
113
+ ...props,
114
+ children: /* @__PURE__ */ jsx("path", {
115
+ d: "M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z",
116
+ fill: "currentColor"
117
+ })
118
+ });
119
+ }
120
+ function PauseIcon(props) {
121
+ return /* @__PURE__ */ jsxs(Icon, {
122
+ ...props,
123
+ children: [/* @__PURE__ */ jsx("rect", {
124
+ x: "14",
125
+ y: "4",
126
+ width: "4",
127
+ height: "16",
128
+ rx: "1.5",
129
+ fill: "currentColor"
130
+ }), /* @__PURE__ */ jsx("rect", {
131
+ x: "6",
132
+ y: "4",
133
+ width: "4",
134
+ height: "16",
135
+ rx: "1.5",
136
+ fill: "currentColor"
137
+ })]
138
+ });
139
+ }
140
+ function VolumeIcon(props) {
141
+ return /* @__PURE__ */ jsxs(Icon, {
142
+ ...props,
143
+ children: [/* @__PURE__ */ jsx("path", {
144
+ d: "M11 5 6 9H3v6h3l5 4z",
145
+ fill: "currentColor",
146
+ stroke: "currentColor",
147
+ strokeWidth: "1.5",
148
+ strokeLinejoin: "round"
149
+ }), /* @__PURE__ */ jsx("path", {
150
+ d: "M16 9a4 4 0 0 1 0 6M19 6.5a8 8 0 0 1 0 11",
151
+ stroke: "currentColor",
152
+ strokeWidth: "2",
153
+ strokeLinecap: "round"
154
+ })]
155
+ });
156
+ }
157
+ function VolumeMutedIcon(props) {
158
+ return /* @__PURE__ */ jsxs(Icon, {
159
+ ...props,
160
+ children: [/* @__PURE__ */ jsx("path", {
161
+ d: "M11 5 6 9H3v6h3l5 4z",
162
+ fill: "currentColor",
163
+ stroke: "currentColor",
164
+ strokeWidth: "1.5",
165
+ strokeLinejoin: "round"
166
+ }), /* @__PURE__ */ jsx("path", {
167
+ d: "M16 9.5 21 15M21 9.5 16 15",
168
+ stroke: "currentColor",
169
+ strokeWidth: "2",
170
+ strokeLinecap: "round"
171
+ })]
172
+ });
173
+ }
174
+ function CcIcon(props) {
175
+ return /* @__PURE__ */ jsxs(Icon, {
176
+ ...props,
177
+ children: [/* @__PURE__ */ jsx("rect", {
178
+ x: "3",
179
+ y: "5",
180
+ width: "18",
181
+ height: "14",
182
+ rx: "2",
183
+ ry: "2",
184
+ stroke: "currentColor",
185
+ strokeWidth: "2"
186
+ }), /* @__PURE__ */ jsx("path", {
187
+ d: "M7 15h4M15 15h2M7 11h2M13 11h4",
188
+ stroke: "currentColor",
189
+ strokeWidth: "2",
190
+ strokeLinecap: "round",
191
+ strokeLinejoin: "round"
192
+ })]
193
+ });
194
+ }
195
+ function CcOffIcon(props) {
196
+ return /* @__PURE__ */ jsx(Icon, {
197
+ ...props,
198
+ children: /* @__PURE__ */ jsx("path", {
199
+ d: "M10.5 5H19a2 2 0 0 1 2 2v8.5M17 11h-.5M19 19H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2M7 11h4M7 15h2.5M2 2l20 20",
200
+ stroke: "currentColor",
201
+ strokeWidth: "2",
202
+ strokeLinecap: "round",
203
+ strokeLinejoin: "round"
204
+ })
205
+ });
206
+ }
207
+ function SettingsIcon(props) {
208
+ return /* @__PURE__ */ jsxs(Icon, {
209
+ ...props,
210
+ children: [/* @__PURE__ */ jsx("circle", {
211
+ cx: "12",
212
+ cy: "12",
213
+ r: "3",
214
+ stroke: "currentColor",
215
+ strokeWidth: "2"
216
+ }), /* @__PURE__ */ jsx("path", {
217
+ 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",
218
+ stroke: "currentColor",
219
+ strokeWidth: "2",
220
+ strokeLinecap: "round",
221
+ strokeLinejoin: "round"
222
+ })]
223
+ });
224
+ }
225
+ function PipIcon(props) {
226
+ return /* @__PURE__ */ jsxs(Icon, {
227
+ ...props,
228
+ children: [/* @__PURE__ */ jsx("rect", {
229
+ x: "3",
230
+ y: "5",
231
+ width: "18",
232
+ height: "14",
233
+ rx: "2",
234
+ stroke: "currentColor",
235
+ strokeWidth: "2"
236
+ }), /* @__PURE__ */ jsx("rect", {
237
+ x: "12",
238
+ y: "11",
239
+ width: "7",
240
+ height: "6",
241
+ rx: "1",
242
+ fill: "currentColor"
243
+ })]
244
+ });
245
+ }
246
+ function FullscreenIcon(props) {
247
+ return /* @__PURE__ */ jsx(Icon, {
248
+ ...props,
249
+ children: /* @__PURE__ */ jsx("path", {
250
+ d: "M15 3h6v6M21 3l-7 7M3 21l7-7M9 21H3v-6",
251
+ stroke: "currentColor",
252
+ strokeWidth: "2",
253
+ strokeLinecap: "round",
254
+ strokeLinejoin: "round"
255
+ })
256
+ });
257
+ }
258
+ function FullscreenExitIcon(props) {
259
+ return /* @__PURE__ */ jsx(Icon, {
260
+ ...props,
261
+ children: /* @__PURE__ */ jsx("path", {
262
+ d: "M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7",
263
+ stroke: "currentColor",
264
+ strokeWidth: "2",
265
+ strokeLinecap: "round",
266
+ strokeLinejoin: "round"
267
+ })
268
+ });
269
+ }
270
+ function SkipBackIcon(props) {
271
+ return /* @__PURE__ */ jsxs(Icon, {
272
+ ...props,
273
+ children: [/* @__PURE__ */ jsx("path", {
274
+ d: "M9 14 4 9l5-5",
275
+ stroke: "currentColor",
276
+ strokeWidth: "2",
277
+ strokeLinecap: "round",
278
+ strokeLinejoin: "round"
279
+ }), /* @__PURE__ */ jsx("path", {
280
+ d: "M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11",
281
+ stroke: "currentColor",
282
+ strokeWidth: "2",
283
+ strokeLinecap: "round",
284
+ strokeLinejoin: "round"
285
+ })]
286
+ });
287
+ }
288
+ function SkipForwardIcon(props) {
289
+ return /* @__PURE__ */ jsxs(Icon, {
290
+ ...props,
291
+ children: [/* @__PURE__ */ jsx("path", {
292
+ d: "m15 14 5-5-5-5",
293
+ stroke: "currentColor",
294
+ strokeWidth: "2",
295
+ strokeLinecap: "round",
296
+ strokeLinejoin: "round"
297
+ }), /* @__PURE__ */ jsx("path", {
298
+ d: "M20 9H9.5A5.5 5.5 0 0 0 4 14.5 5.5 5.5 0 0 0 9.5 20H13",
299
+ stroke: "currentColor",
300
+ strokeWidth: "2",
301
+ strokeLinecap: "round",
302
+ strokeLinejoin: "round"
303
+ })]
304
+ });
305
+ }
306
+ function SkipBack5Icon(props) {
307
+ return /* @__PURE__ */ jsxs(Icon, {
308
+ ...props,
309
+ children: [
310
+ /* @__PURE__ */ jsx("path", {
311
+ d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",
312
+ stroke: "currentColor",
313
+ strokeWidth: "2",
314
+ strokeLinecap: "round",
315
+ strokeLinejoin: "round"
316
+ }),
317
+ /* @__PURE__ */ jsx("path", {
318
+ d: "M3 3v5h5",
319
+ stroke: "currentColor",
320
+ strokeWidth: "2",
321
+ strokeLinecap: "round",
322
+ strokeLinejoin: "round"
323
+ }),
324
+ /* @__PURE__ */ jsx("text", {
325
+ x: "12",
326
+ y: "13",
327
+ textAnchor: "middle",
328
+ dominantBaseline: "central",
329
+ fontSize: "9",
330
+ fontWeight: "700",
331
+ fill: "currentColor",
332
+ stroke: "none",
333
+ children: "5"
334
+ })
335
+ ]
336
+ });
337
+ }
338
+ function SkipForward5Icon(props) {
339
+ return /* @__PURE__ */ jsxs(Icon, {
340
+ ...props,
341
+ children: [
342
+ /* @__PURE__ */ jsx("path", {
343
+ d: "M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",
344
+ stroke: "currentColor",
345
+ strokeWidth: "2",
346
+ strokeLinecap: "round",
347
+ strokeLinejoin: "round"
348
+ }),
349
+ /* @__PURE__ */ jsx("path", {
350
+ d: "M21 3v5h-5",
351
+ stroke: "currentColor",
352
+ strokeWidth: "2",
353
+ strokeLinecap: "round",
354
+ strokeLinejoin: "round"
355
+ }),
356
+ /* @__PURE__ */ jsx("text", {
357
+ x: "12",
358
+ y: "13",
359
+ textAnchor: "middle",
360
+ dominantBaseline: "central",
361
+ fontSize: "9",
362
+ fontWeight: "700",
363
+ fill: "currentColor",
364
+ stroke: "none",
365
+ children: "5"
366
+ })
367
+ ]
368
+ });
369
+ }
370
+ function ChevronLeftIcon(props) {
371
+ return /* @__PURE__ */ jsx(Icon, {
372
+ ...props,
373
+ children: /* @__PURE__ */ jsx("path", {
374
+ d: "m15 6-6 6 6 6",
375
+ stroke: "currentColor",
376
+ strokeWidth: "2",
377
+ strokeLinecap: "round",
378
+ strokeLinejoin: "round"
379
+ })
380
+ });
381
+ }
382
+ function ChevronRightIcon(props) {
383
+ return /* @__PURE__ */ jsx(Icon, {
384
+ ...props,
385
+ children: /* @__PURE__ */ jsx("path", {
386
+ d: "m9 6 6 6-6 6",
387
+ stroke: "currentColor",
388
+ strokeWidth: "2",
389
+ strokeLinecap: "round",
390
+ strokeLinejoin: "round"
391
+ })
392
+ });
393
+ }
394
+ //#endregion
395
+ //#region src/ui/player.tsx
396
+ const COMPACT_MAX = 560;
397
+ const WrapperContext = createContext(null);
398
+ function useWrapperRef() {
399
+ return useContext(WrapperContext);
400
+ }
401
+ const CompactContext = createContext(false);
402
+ function useIsCompact() {
403
+ return useContext(CompactContext);
404
+ }
405
+ const ControlsVisibilityContext = createContext(null);
406
+ function Player({ provider, accentColor, theme, className, children }) {
407
+ const wrapperRef = useRef(null);
408
+ const videoHostRef = useRef(null);
409
+ const hoveredRef = useRef(false);
410
+ const [compact, setCompact] = useState(false);
411
+ useEffect(() => {
412
+ const host = videoHostRef.current;
413
+ if (!host) return;
414
+ provider.mount(host);
415
+ return () => provider.destroy();
416
+ }, [provider]);
417
+ useEffect(() => {
418
+ const el = wrapperRef.current;
419
+ if (!el) return;
420
+ const update = () => setCompact(el.clientWidth > 0 && el.clientWidth <= COMPACT_MAX);
421
+ update();
422
+ if (typeof ResizeObserver === "undefined") return;
423
+ const ro = new ResizeObserver(update);
424
+ ro.observe(el);
425
+ return () => ro.disconnect();
426
+ }, []);
427
+ useEffect(() => {
428
+ const root = wrapperRef.current;
429
+ if (!root) return;
430
+ const enter = () => hoveredRef.current = true;
431
+ const leave = () => hoveredRef.current = false;
432
+ root.addEventListener("pointerenter", enter);
433
+ root.addEventListener("pointerleave", leave);
434
+ return () => {
435
+ root.removeEventListener("pointerenter", enter);
436
+ root.removeEventListener("pointerleave", leave);
437
+ };
438
+ }, []);
439
+ useEffect(() => {
440
+ function onKeyDown(e) {
441
+ const root = wrapperRef.current;
442
+ if (!root) return;
443
+ const target = e.target;
444
+ if (!(target != null && root.contains(target) || hoveredRef.current || root.contains(document.activeElement))) return;
445
+ if (isTypingTarget(e.target)) return;
446
+ const action = resolveKey(e);
447
+ if (!action) return;
448
+ e.preventDefault();
449
+ const s = provider.getState();
450
+ const a = provider.actions;
451
+ switch (action.type) {
452
+ case "toggle-play":
453
+ if (s.paused) a.play();
454
+ else a.pause();
455
+ break;
456
+ case "seek-by":
457
+ a.seek(Math.max(0, s.currentTime + action.delta));
458
+ break;
459
+ case "volume-by":
460
+ a.setVolume(s.volume + action.delta);
461
+ break;
462
+ case "toggle-mute":
463
+ a.setMuted(!s.muted);
464
+ break;
465
+ case "toggle-fullscreen":
466
+ if (s.fullscreen) a.exitFullscreen();
467
+ else if (wrapperRef.current) a.enterFullscreen(wrapperRef.current);
468
+ break;
469
+ case "seek-percent":
470
+ if (s.duration) a.seek(action.percent / 100 * s.duration);
471
+ break;
472
+ case "rate-by":
473
+ a.setRate(Math.max(.25, s.rate + action.delta));
474
+ break;
475
+ case "toggle-captions": {
476
+ const next = s.activeTextTrackId ? null : s.textTracks[0]?.id ?? null;
477
+ a.setTextTrack(next);
478
+ break;
479
+ }
480
+ case "open-speed":
481
+ wrapperRef.current?.dispatchEvent(new Event("kino:open-speed"));
482
+ break;
483
+ }
484
+ }
485
+ window.addEventListener("keydown", onKeyDown);
486
+ return () => window.removeEventListener("keydown", onKeyDown);
487
+ }, [provider]);
488
+ const style = { ...theme };
489
+ if (accentColor) style["--kino-accent"] = accentColor;
490
+ return /* @__PURE__ */ jsx(PlayerContext.Provider, {
491
+ value: provider,
492
+ children: /* @__PURE__ */ jsx(WrapperContext.Provider, {
493
+ value: wrapperRef,
494
+ children: /* @__PURE__ */ jsxs("div", {
495
+ ref: wrapperRef,
496
+ className: ["kino", className].filter(Boolean).join(" "),
497
+ style,
498
+ tabIndex: 0,
499
+ children: [/* @__PURE__ */ jsx("div", {
500
+ ref: videoHostRef,
501
+ className: "kino-video-host"
502
+ }), /* @__PURE__ */ jsx(PlayerChrome, {
503
+ compact,
504
+ children
505
+ })]
506
+ })
507
+ })
508
+ });
509
+ }
510
+ function PlayerChrome({ compact, children }) {
511
+ const paused = useMediaSelector((s) => s.paused);
512
+ const visibility = useVisibilityManager(useWrapperRef(), compact, paused);
513
+ return /* @__PURE__ */ jsx(CompactContext.Provider, {
514
+ value: compact,
515
+ children: /* @__PURE__ */ jsxs(ControlsVisibilityContext.Provider, {
516
+ value: visibility,
517
+ children: [/* @__PURE__ */ jsx(GestureLayer, {
518
+ compact,
519
+ onToggleControls: visibility.toggle
520
+ }), children]
521
+ })
522
+ });
523
+ }
524
+ function useVisibilityManager(wrapperRef, compact, paused) {
525
+ const [visible, setVisible] = useState(true);
526
+ const timerRef = useRef(void 0);
527
+ const pausedRef = useRef(paused);
528
+ pausedRef.current = paused;
529
+ const arm = useCallback(() => {
530
+ clearTimeout(timerRef.current);
531
+ if (!pausedRef.current) timerRef.current = setTimeout(() => setVisible(false), compact ? 3200 : 2500);
532
+ }, [compact]);
533
+ const show = useCallback(() => {
534
+ setVisible(true);
535
+ arm();
536
+ }, [arm]);
537
+ const toggle = useCallback(() => {
538
+ if (visible) {
539
+ clearTimeout(timerRef.current);
540
+ setVisible(false);
541
+ } else {
542
+ setVisible(true);
543
+ arm();
544
+ }
545
+ }, [visible, arm]);
546
+ useEffect(() => {
547
+ const root = wrapperRef?.current;
548
+ if (!root || compact) return;
549
+ const onShow = () => show();
550
+ const onHide = () => {
551
+ clearTimeout(timerRef.current);
552
+ if (!pausedRef.current) setVisible(false);
553
+ };
554
+ root.addEventListener("pointermove", onShow);
555
+ root.addEventListener("pointerenter", onShow);
556
+ root.addEventListener("pointerleave", onHide);
557
+ root.addEventListener("focusin", onShow);
558
+ show();
559
+ return () => {
560
+ clearTimeout(timerRef.current);
561
+ root.removeEventListener("pointermove", onShow);
562
+ root.removeEventListener("pointerenter", onShow);
563
+ root.removeEventListener("pointerleave", onHide);
564
+ root.removeEventListener("focusin", onShow);
565
+ };
566
+ }, [
567
+ wrapperRef,
568
+ compact,
569
+ show
570
+ ]);
571
+ useEffect(() => {
572
+ if (compact && !paused) arm();
573
+ }, [
574
+ compact,
575
+ paused,
576
+ arm
577
+ ]);
578
+ useEffect(() => () => clearTimeout(timerRef.current), []);
579
+ return {
580
+ visible: visible || paused,
581
+ show,
582
+ toggle
583
+ };
584
+ }
585
+ function GestureLayer({ compact, onToggleControls }) {
586
+ const actions = usePlayerActions();
587
+ const paused = useMediaSelector((s) => s.paused);
588
+ const fullscreen = useMediaSelector((s) => s.fullscreen);
589
+ const wrapperRef = useWrapperRef();
590
+ const clickTimer = useRef(void 0);
591
+ useEffect(() => () => clearTimeout(clickTimer.current), []);
592
+ if (compact) return /* @__PURE__ */ jsx("div", {
593
+ className: "kino-gesture",
594
+ onClick: onToggleControls,
595
+ children: /* @__PURE__ */ jsx(CenterFlash, {})
596
+ });
597
+ const togglePlay = () => paused ? actions.play() : actions.pause();
598
+ const toggleFullscreen = () => {
599
+ if (fullscreen) actions.exitFullscreen();
600
+ else if (wrapperRef?.current) actions.enterFullscreen(wrapperRef.current);
601
+ };
602
+ return /* @__PURE__ */ jsx("div", {
603
+ className: "kino-gesture",
604
+ onClick: () => {
605
+ clearTimeout(clickTimer.current);
606
+ clickTimer.current = setTimeout(togglePlay, 220);
607
+ },
608
+ onDoubleClick: () => {
609
+ clearTimeout(clickTimer.current);
610
+ toggleFullscreen();
611
+ },
612
+ children: /* @__PURE__ */ jsx(CenterFlash, {})
613
+ });
614
+ }
615
+ function CenterFlash() {
616
+ const paused = useMediaSelector((s) => s.paused);
617
+ const [pulse, setPulse] = useState(null);
618
+ const firstRun = useRef(true);
619
+ const seq = useRef(0);
620
+ useEffect(() => {
621
+ if (firstRun.current) {
622
+ firstRun.current = false;
623
+ return;
624
+ }
625
+ seq.current += 1;
626
+ setPulse({
627
+ id: seq.current,
628
+ paused
629
+ });
630
+ }, [paused]);
631
+ if (!pulse) return null;
632
+ return /* @__PURE__ */ jsx("div", {
633
+ className: "kino-flash",
634
+ "aria-hidden": "true",
635
+ children: pulse.paused ? /* @__PURE__ */ jsx(PauseIcon, {}) : /* @__PURE__ */ jsx(PlayIcon, {})
636
+ }, pulse.id);
637
+ }
638
+ function Overlay({ children }) {
639
+ return /* @__PURE__ */ jsx("div", {
640
+ className: "kino-overlay",
641
+ children
642
+ });
643
+ }
644
+ Player.Overlay = Overlay;
645
+ function useControlsVisible() {
646
+ return useContext(ControlsVisibilityContext)?.visible ?? true;
647
+ }
648
+ //#endregion
649
+ //#region src/util/storyboard.ts
650
+ function toSeconds(stamp) {
651
+ const parts = stamp.trim().split(":").map(Number);
652
+ let s = 0;
653
+ for (const p of parts) s = s * 60 + p;
654
+ return s;
655
+ }
656
+ function parseStoryboard(vttText, baseUrl) {
657
+ const lines = vttText.split(/\r?\n/);
658
+ const tiles = [];
659
+ for (let i = 0; i < lines.length; i++) {
660
+ const line = lines[i];
661
+ if (!line || !line.includes("-->")) continue;
662
+ const [from, to] = line.split("-->");
663
+ if (from === void 0 || to === void 0) continue;
664
+ const start = toSeconds(from);
665
+ const end = toSeconds(to);
666
+ const m = (lines[i + 1] ?? "").trim().match(/^(.*?)#xywh=(\d+),(\d+),(\d+),(\d+)/);
667
+ if (!m) continue;
668
+ const [, file, x, y, w, h] = m;
669
+ if (file === void 0 || x === void 0 || y === void 0 || w === void 0 || h === void 0) continue;
670
+ tiles.push({
671
+ url: new URL(file, baseUrl).href,
672
+ x: +x,
673
+ y: +y,
674
+ w: +w,
675
+ h: +h,
676
+ start,
677
+ end
678
+ });
679
+ }
680
+ return {
681
+ tiles,
682
+ thumbnailAt(time) {
683
+ if (tiles.length === 0) return null;
684
+ for (const t of tiles) if (time >= t.start && time < t.end) return t;
685
+ return tiles[tiles.length - 1] ?? null;
686
+ }
687
+ };
688
+ }
689
+ //#endregion
690
+ //#region src/ui/rolling-time.tsx
691
+ const DIGITS = [
692
+ 0,
693
+ 1,
694
+ 2,
695
+ 3,
696
+ 4,
697
+ 5,
698
+ 6,
699
+ 7,
700
+ 8,
701
+ 9
702
+ ];
703
+ function RollingDigit({ value }) {
704
+ return /* @__PURE__ */ jsx("span", {
705
+ className: "kino-roll-digit",
706
+ children: /* @__PURE__ */ jsx("span", {
707
+ className: "kino-roll-col",
708
+ style: { transform: `translateY(${-value * 10}%)` },
709
+ children: DIGITS.map((d) => /* @__PURE__ */ jsx("span", {
710
+ className: "kino-roll-cell",
711
+ children: d
712
+ }, d))
713
+ })
714
+ });
715
+ }
716
+ function RollingTime({ value }) {
717
+ return /* @__PURE__ */ jsx("span", {
718
+ className: "kino-roll",
719
+ "aria-label": value,
720
+ children: value.split("").map((ch, i) => ch >= "0" && ch <= "9" ? /* @__PURE__ */ jsx(RollingDigit, { value: Number(ch) }, i) : /* @__PURE__ */ jsx("span", {
721
+ className: "kino-roll-sep",
722
+ "aria-hidden": "true",
723
+ children: ch
724
+ }, i))
725
+ });
726
+ }
727
+ //#endregion
728
+ //#region src/ui/scrubber.tsx
729
+ function Scrubber() {
730
+ const actions = usePlayerActions();
731
+ const duration = useMediaSelector((s) => s.duration);
732
+ const currentTime = useMediaSelector((s) => s.currentTime);
733
+ const buffered = useMediaSelector((s) => s.buffered);
734
+ const storyboardUrl = useMediaSelector((s) => s.storyboard?.vttUrl ?? null);
735
+ const hasStoryboard = useMediaSelector((s) => s.capabilities.hasStoryboard);
736
+ const trackRef = useRef(null);
737
+ const previewRef = useRef(null);
738
+ const dragCleanup = useRef(null);
739
+ useEffect(() => () => dragCleanup.current?.(), []);
740
+ const [hover, setHover] = useState(null);
741
+ const [sb, setSb] = useState(null);
742
+ const [dims, setDims] = useState({
743
+ pw: 0,
744
+ tw: 0
745
+ });
746
+ useEffect(() => {
747
+ if (!hasStoryboard || !storyboardUrl) return;
748
+ let alive = true;
749
+ fetch(storyboardUrl).then((r) => r.text()).then((txt) => {
750
+ if (alive) setSb(parseStoryboard(txt, storyboardUrl));
751
+ }).catch(() => {});
752
+ return () => {
753
+ alive = false;
754
+ };
755
+ }, [hasStoryboard, storyboardUrl]);
756
+ const pct = duration > 0 ? currentTime / duration * 100 : 0;
757
+ const timeFromClientX = (clientX) => {
758
+ const rect = trackRef.current?.getBoundingClientRect();
759
+ if (!rect || rect.width === 0) return 0;
760
+ return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) * duration;
761
+ };
762
+ const onPointerDown = (e) => {
763
+ actions.seek(timeFromClientX(e.clientX));
764
+ const move = (ev) => actions.seek(timeFromClientX(ev.clientX));
765
+ const up = () => {
766
+ window.removeEventListener("pointermove", move);
767
+ window.removeEventListener("pointerup", up);
768
+ window.removeEventListener("pointercancel", up);
769
+ dragCleanup.current = null;
770
+ };
771
+ dragCleanup.current = up;
772
+ window.addEventListener("pointermove", move);
773
+ window.addEventListener("pointerup", up);
774
+ window.addEventListener("pointercancel", up);
775
+ };
776
+ const onPointerMove = (e) => {
777
+ const rect = trackRef.current?.getBoundingClientRect();
778
+ if (!rect) return;
779
+ setHover({
780
+ x: e.clientX - rect.left,
781
+ time: timeFromClientX(e.clientX)
782
+ });
783
+ };
784
+ const tile = sb && hover ? sb.thumbnailAt(hover.time) : null;
785
+ useLayoutEffect(() => {
786
+ setDims({
787
+ pw: previewRef.current?.getBoundingClientRect().width ?? 0,
788
+ tw: trackRef.current?.getBoundingClientRect().width ?? 0
789
+ });
790
+ }, [hover, tile?.url]);
791
+ let previewLeft = hover?.x ?? 0;
792
+ if (hover && dims.pw > 0 && dims.tw > 0) {
793
+ const half = dims.pw / 2 + 4;
794
+ previewLeft = Math.min(Math.max(hover.x, half), dims.tw - half);
795
+ }
796
+ return /* @__PURE__ */ jsxs("div", {
797
+ className: "kino-scrubber",
798
+ onPointerMove,
799
+ onPointerLeave: () => setHover(null),
800
+ children: [hover && /* @__PURE__ */ jsxs("div", {
801
+ ref: previewRef,
802
+ className: "kino-preview kino-glass",
803
+ style: { left: previewLeft },
804
+ children: [tile && /* @__PURE__ */ jsx("div", {
805
+ className: "kino-preview-img",
806
+ style: {
807
+ width: tile.w,
808
+ height: tile.h,
809
+ backgroundImage: `url(${tile.url})`,
810
+ backgroundPosition: `-${tile.x}px -${tile.y}px`
811
+ }
812
+ }), /* @__PURE__ */ jsx("span", {
813
+ className: "kino-preview-time",
814
+ children: /* @__PURE__ */ jsx(RollingTime, { value: formatTime(hover.time) })
815
+ })]
816
+ }), /* @__PURE__ */ jsxs("div", {
817
+ ref: trackRef,
818
+ "data-testid": "kino-track",
819
+ className: "kino-track",
820
+ role: "slider",
821
+ tabIndex: 0,
822
+ "aria-label": "Seek",
823
+ "aria-valuemin": 0,
824
+ "aria-valuemax": Math.floor(duration) || 0,
825
+ "aria-valuenow": Math.floor(currentTime),
826
+ "aria-valuetext": formatTime(currentTime),
827
+ onPointerDown,
828
+ children: [
829
+ buffered.map(([s, e], i) => /* @__PURE__ */ jsx("div", {
830
+ className: "kino-buffered",
831
+ style: {
832
+ left: `${duration > 0 ? s / duration * 100 : 0}%`,
833
+ width: `${duration > 0 ? (e - s) / duration * 100 : 0}%`
834
+ }
835
+ }, i)),
836
+ /* @__PURE__ */ jsx("div", {
837
+ "data-testid": "kino-progress",
838
+ className: "kino-progress",
839
+ style: { width: `${pct}%` }
840
+ }),
841
+ /* @__PURE__ */ jsx("div", {
842
+ className: "kino-thumb",
843
+ style: { left: `${pct}%` }
844
+ })
845
+ ]
846
+ })]
847
+ });
848
+ }
849
+ //#endregion
850
+ //#region src/ui/idle-overlay.tsx
851
+ const SPEEDS = [
852
+ .8,
853
+ 1,
854
+ 1.2,
855
+ 1.5,
856
+ 1.7,
857
+ 2,
858
+ 2.5
859
+ ];
860
+ const MAX_RATE = 2.5;
861
+ function IdleOverlay() {
862
+ const actions = usePlayerActions();
863
+ const compact = useIsCompact();
864
+ const paused = useMediaSelector((s) => s.paused);
865
+ const currentTime = useMediaSelector((s) => s.currentTime);
866
+ const ended = useMediaSelector((s) => s.ended);
867
+ const rate = useMediaSelector((s) => s.rate);
868
+ if (!paused || currentTime > 0 || ended) return null;
869
+ const startAt = (r) => {
870
+ actions.setRate(r);
871
+ actions.play();
872
+ };
873
+ return /* @__PURE__ */ jsxs("div", {
874
+ className: "kino-idle",
875
+ onClick: () => actions.play(),
876
+ children: [/* @__PURE__ */ jsx("button", {
877
+ type: "button",
878
+ className: "kino-idle-play",
879
+ "aria-label": "Play",
880
+ onClick: () => actions.play(),
881
+ children: /* @__PURE__ */ jsx(PlayIcon, {})
882
+ }), !compact && /* @__PURE__ */ jsx("div", {
883
+ className: "kino-idle-speeds",
884
+ children: SPEEDS.map((r, i) => {
885
+ const isMax = r === MAX_RATE;
886
+ return /* @__PURE__ */ jsx("button", {
887
+ type: "button",
888
+ className: "kino-speed-chip",
889
+ style: { "--i": i },
890
+ "aria-label": isMax ? "Max" : `${r}x`,
891
+ "aria-pressed": r === rate,
892
+ "data-active": r === rate,
893
+ "data-max": isMax,
894
+ onClick: () => startAt(r),
895
+ children: isMax ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
896
+ className: "kino-bolt",
897
+ "aria-hidden": "true",
898
+ children: "⚡"
899
+ }), "Max"] }) : `${r}×`
900
+ }, r);
901
+ })
902
+ })]
903
+ });
904
+ }
905
+ //#endregion
906
+ //#region src/ui/captions.tsx
907
+ function Captions() {
908
+ const text = useMediaSelector((s) => s.activeCueText);
909
+ const controlsVisible = useControlsVisible();
910
+ if (!text) return null;
911
+ return /* @__PURE__ */ jsx("div", {
912
+ className: `kino-captions ${controlsVisible ? "is-raised" : ""}`,
913
+ "aria-live": "polite",
914
+ children: /* @__PURE__ */ jsx("span", {
915
+ className: "kino-caption-text",
916
+ children: text
917
+ })
918
+ });
919
+ }
920
+ //#endregion
921
+ //#region src/ui/tooltip.tsx
922
+ function renderShortcut(shortcut) {
923
+ if (shortcut == null) return null;
924
+ return typeof shortcut === "string" ? /* @__PURE__ */ jsx("kbd", { children: shortcut }) : shortcut;
925
+ }
926
+ function SpaceKey() {
927
+ return /* @__PURE__ */ jsx("span", {
928
+ className: "kino-key kino-key-space",
929
+ "aria-hidden": "true",
930
+ children: /* @__PURE__ */ jsx("span", { className: "kino-key-space-bar" })
931
+ });
932
+ }
933
+ function ArrowKey({ dir }) {
934
+ return /* @__PURE__ */ jsx("span", {
935
+ className: "kino-key kino-key-arrow",
936
+ "aria-hidden": "true",
937
+ children: dir === "left" ? /* @__PURE__ */ jsx(ChevronLeftIcon, {}) : /* @__PURE__ */ jsx(ChevronRightIcon, {})
938
+ });
939
+ }
940
+ function Tooltip({ label, shortcut, align = "center", children }) {
941
+ return /* @__PURE__ */ jsxs("span", {
942
+ className: "kino-popover-root",
943
+ children: [children, /* @__PURE__ */ jsxs("span", {
944
+ className: `kino-tip kino-glass kino-tip-${align}`,
945
+ "aria-hidden": "true",
946
+ children: [label, renderShortcut(shortcut)]
947
+ })]
948
+ });
949
+ }
950
+ //#endregion
951
+ //#region src/ui/buttons.tsx
952
+ const SKIP_SECONDS$1 = 5;
953
+ function PlayPauseButton() {
954
+ const actions = usePlayerActions();
955
+ const paused = useMediaSelector((s) => s.paused);
956
+ return /* @__PURE__ */ jsx(Tooltip, {
957
+ label: paused ? "Play" : "Pause",
958
+ shortcut: /* @__PURE__ */ jsx(SpaceKey, {}),
959
+ align: "start",
960
+ children: /* @__PURE__ */ jsx("button", {
961
+ type: "button",
962
+ className: "kino-ctrl",
963
+ "aria-label": paused ? "Play" : "Pause",
964
+ onClick: () => paused ? actions.play() : actions.pause(),
965
+ children: paused ? /* @__PURE__ */ jsx(PlayIcon, {}) : /* @__PURE__ */ jsx(PauseIcon, {})
966
+ })
967
+ });
968
+ }
969
+ function SkipBackButton() {
970
+ const actions = usePlayerActions();
971
+ const currentTime = useMediaSelector((s) => s.currentTime);
972
+ return /* @__PURE__ */ jsx(Tooltip, {
973
+ label: "Back",
974
+ shortcut: /* @__PURE__ */ jsx(ArrowKey, { dir: "left" }),
975
+ children: /* @__PURE__ */ jsxs("button", {
976
+ type: "button",
977
+ className: "kino-ctrl kino-ctrl-skip",
978
+ "aria-label": `Back ${SKIP_SECONDS$1} seconds`,
979
+ onClick: () => actions.seek(Math.max(0, currentTime - SKIP_SECONDS$1)),
980
+ children: [/* @__PURE__ */ jsx(SkipBackIcon, {}), /* @__PURE__ */ jsx("span", {
981
+ className: "kino-ctrl-skip-num",
982
+ children: SKIP_SECONDS$1
983
+ })]
984
+ })
985
+ });
986
+ }
987
+ function SkipForwardButton() {
988
+ const actions = usePlayerActions();
989
+ const currentTime = useMediaSelector((s) => s.currentTime);
990
+ const duration = useMediaSelector((s) => s.duration);
991
+ return /* @__PURE__ */ jsx(Tooltip, {
992
+ label: "Forward",
993
+ shortcut: /* @__PURE__ */ jsx(ArrowKey, { dir: "right" }),
994
+ children: /* @__PURE__ */ jsxs("button", {
995
+ type: "button",
996
+ className: "kino-ctrl kino-ctrl-skip",
997
+ "aria-label": `Forward ${SKIP_SECONDS$1} seconds`,
998
+ onClick: () => actions.seek(duration ? Math.min(duration, currentTime + SKIP_SECONDS$1) : currentTime + SKIP_SECONDS$1),
999
+ children: [/* @__PURE__ */ jsx(SkipForwardIcon, {}), /* @__PURE__ */ jsx("span", {
1000
+ className: "kino-ctrl-skip-num",
1001
+ children: SKIP_SECONDS$1
1002
+ })]
1003
+ })
1004
+ });
1005
+ }
1006
+ function VolumeControl() {
1007
+ const actions = usePlayerActions();
1008
+ const volume = useMediaSelector((s) => s.volume);
1009
+ const muted = useMediaSelector((s) => s.muted);
1010
+ const onChange = (e) => {
1011
+ const v = Number(e.target.value);
1012
+ actions.setVolume(v);
1013
+ if (muted && v > 0) actions.setMuted(false);
1014
+ };
1015
+ return /* @__PURE__ */ jsxs("div", {
1016
+ className: "kino-volume",
1017
+ children: [/* @__PURE__ */ jsx(Tooltip, {
1018
+ label: muted ? "Unmute" : "Mute",
1019
+ shortcut: "M",
1020
+ children: /* @__PURE__ */ jsx("button", {
1021
+ type: "button",
1022
+ className: "kino-ctrl",
1023
+ "aria-label": muted ? "Unmute" : "Mute",
1024
+ onClick: () => actions.setMuted(!muted),
1025
+ children: muted ? /* @__PURE__ */ jsx(VolumeMutedIcon, {}) : /* @__PURE__ */ jsx(VolumeIcon, {})
1026
+ })
1027
+ }), /* @__PURE__ */ jsx("input", {
1028
+ type: "range",
1029
+ min: 0,
1030
+ max: 1,
1031
+ step: .05,
1032
+ "aria-label": "Volume",
1033
+ value: muted ? 0 : volume,
1034
+ onChange
1035
+ })]
1036
+ });
1037
+ }
1038
+ function PipButton() {
1039
+ const actions = usePlayerActions();
1040
+ const pip = useMediaSelector((s) => s.pip);
1041
+ if (!useMediaSelector((s) => s.capabilities.canPiP)) return null;
1042
+ return /* @__PURE__ */ jsx(Tooltip, {
1043
+ label: pip ? "Exit picture in picture" : "Picture in picture",
1044
+ align: "end",
1045
+ children: /* @__PURE__ */ jsx("button", {
1046
+ type: "button",
1047
+ className: "kino-ctrl",
1048
+ "aria-label": pip ? "Exit picture in picture" : "Picture in picture",
1049
+ onClick: () => pip ? actions.exitPiP() : actions.enterPiP(),
1050
+ children: /* @__PURE__ */ jsx(PipIcon, {})
1051
+ })
1052
+ });
1053
+ }
1054
+ function FullscreenButton() {
1055
+ const actions = usePlayerActions();
1056
+ const wrapperRef = useWrapperRef();
1057
+ const fullscreen = useMediaSelector((s) => s.fullscreen);
1058
+ if (!useMediaSelector((s) => s.capabilities.canFullscreen)) return null;
1059
+ const toggle = () => {
1060
+ if (fullscreen) {
1061
+ actions.exitFullscreen();
1062
+ return;
1063
+ }
1064
+ const wrapper = wrapperRef?.current;
1065
+ if (wrapper) actions.enterFullscreen(wrapper);
1066
+ };
1067
+ return /* @__PURE__ */ jsx(Tooltip, {
1068
+ label: fullscreen ? "Exit fullscreen" : "Fullscreen",
1069
+ shortcut: "F",
1070
+ align: "end",
1071
+ children: /* @__PURE__ */ jsx("button", {
1072
+ type: "button",
1073
+ className: "kino-ctrl",
1074
+ "aria-label": fullscreen ? "Exit fullscreen" : "Fullscreen",
1075
+ onClick: toggle,
1076
+ children: fullscreen ? /* @__PURE__ */ jsx(FullscreenExitIcon, {}) : /* @__PURE__ */ jsx(FullscreenIcon, {})
1077
+ })
1078
+ });
1079
+ }
1080
+ //#endregion
1081
+ //#region src/ui/popover.tsx
1082
+ function Popover({ trigger, shortcut, label, openOn, align = "center", children }) {
1083
+ const [open, setOpen] = useState(false);
1084
+ const [justClosed, setJustClosed] = useState(false);
1085
+ const rootRef = useRef(null);
1086
+ const wrapperRef = useWrapperRef();
1087
+ useEffect(() => {
1088
+ if (!openOn) return;
1089
+ const wrapper = wrapperRef?.current;
1090
+ if (!wrapper) return;
1091
+ const handler = () => setOpen(true);
1092
+ wrapper.addEventListener(openOn, handler);
1093
+ return () => wrapper.removeEventListener(openOn, handler);
1094
+ }, [openOn, wrapperRef]);
1095
+ useEffect(() => {
1096
+ if (!open) return;
1097
+ const onKey = (e) => {
1098
+ if (e.key === "Escape") setOpen(false);
1099
+ };
1100
+ const onPointerDown = (e) => {
1101
+ if (!rootRef.current?.contains(e.target)) setOpen(false);
1102
+ };
1103
+ document.addEventListener("keydown", onKey);
1104
+ document.addEventListener("pointerdown", onPointerDown);
1105
+ return () => {
1106
+ document.removeEventListener("keydown", onKey);
1107
+ document.removeEventListener("pointerdown", onPointerDown);
1108
+ };
1109
+ }, [open]);
1110
+ return /* @__PURE__ */ jsxs("div", {
1111
+ className: "kino-popover-root",
1112
+ ref: rootRef,
1113
+ onPointerLeave: () => setJustClosed(false),
1114
+ children: [
1115
+ /* @__PURE__ */ jsx("button", {
1116
+ className: "kino-ctrl",
1117
+ "aria-label": label,
1118
+ "aria-haspopup": "menu",
1119
+ "aria-expanded": open,
1120
+ onClick: () => setOpen((o) => !o),
1121
+ children: trigger
1122
+ }),
1123
+ !open && !justClosed && /* @__PURE__ */ jsxs("span", {
1124
+ className: `kino-tip kino-glass kino-tip-${align}`,
1125
+ "aria-hidden": "true",
1126
+ children: [label, renderShortcut(shortcut)]
1127
+ }),
1128
+ open && /* @__PURE__ */ jsx("div", {
1129
+ className: `kino-menu kino-glass kino-menu-${align}`,
1130
+ role: "menu",
1131
+ onClick: () => {
1132
+ setOpen(false);
1133
+ setJustClosed(true);
1134
+ },
1135
+ children
1136
+ })
1137
+ ]
1138
+ });
1139
+ }
1140
+ //#endregion
1141
+ //#region src/ui/menus.tsx
1142
+ const RATES$1 = [
1143
+ .8,
1144
+ 1,
1145
+ 1.2,
1146
+ 1.5,
1147
+ 1.7,
1148
+ 2,
1149
+ 2.5
1150
+ ];
1151
+ const QUALITY_TIERS$1 = [
1152
+ [2160, "4K"],
1153
+ [1440, "2K"],
1154
+ [1080, "FHD"],
1155
+ [720, "HD"],
1156
+ [480, "SD"]
1157
+ ];
1158
+ const heightKeyword$1 = (h) => {
1159
+ for (const [min, kw] of QUALITY_TIERS$1) if (h >= min) return kw;
1160
+ return `${h}p`;
1161
+ };
1162
+ function SpeedMenu() {
1163
+ const actions = usePlayerActions();
1164
+ const rate = useMediaSelector((s) => s.rate);
1165
+ if (!useMediaSelector((s) => s.capabilities.canSetRate)) return null;
1166
+ return /* @__PURE__ */ jsx(Popover, {
1167
+ label: "Speed",
1168
+ shortcut: "S",
1169
+ openOn: "kino:open-speed",
1170
+ trigger: /* @__PURE__ */ jsx("span", {
1171
+ className: "kino-ctrl-label kino-speed-label",
1172
+ children: rate === 2.5 ? "Max" : `${rate} ×`
1173
+ }),
1174
+ children: RATES$1.map((r) => /* @__PURE__ */ jsx("button", {
1175
+ role: "menuitemradio",
1176
+ "aria-checked": r === rate,
1177
+ className: "kino-menu-item",
1178
+ onClick: () => actions.setRate(r),
1179
+ children: r === 2.5 ? "Max" : `${r}x`
1180
+ }, r))
1181
+ });
1182
+ }
1183
+ function QualityMenu() {
1184
+ const actions = usePlayerActions();
1185
+ const qualities = useMediaSelector((s) => s.qualities);
1186
+ const active = useMediaSelector((s) => s.activeQualityId);
1187
+ const videoHeight = useMediaSelector((s) => s.videoHeight);
1188
+ if (!useMediaSelector((s) => s.capabilities.canSetQuality) || qualities.length === 0) return null;
1189
+ const topHeight = Math.max(...qualities.map((q) => q.height));
1190
+ const badgeHeight = active === "auto" ? videoHeight || topHeight : qualities.find((q) => q.id === active)?.height ?? topHeight;
1191
+ const autoHint = videoHeight ? heightKeyword$1(videoHeight) : "";
1192
+ const sorted = [...qualities].sort((a, b) => b.height - a.height);
1193
+ return /* @__PURE__ */ jsxs(Popover, {
1194
+ label: "Quality",
1195
+ align: "end",
1196
+ trigger: /* @__PURE__ */ jsx("span", {
1197
+ className: "kino-quality-badge",
1198
+ children: heightKeyword$1(badgeHeight)
1199
+ }),
1200
+ children: [/* @__PURE__ */ jsx("button", {
1201
+ role: "menuitemradio",
1202
+ "aria-checked": active === "auto",
1203
+ className: "kino-menu-item kino-menu-q",
1204
+ onClick: () => actions.setQuality("auto"),
1205
+ children: /* @__PURE__ */ jsxs("span", {
1206
+ className: "kino-q-label",
1207
+ children: [/* @__PURE__ */ jsx("span", {
1208
+ className: "kino-q-key",
1209
+ children: "Auto"
1210
+ }), autoHint && /* @__PURE__ */ jsxs("span", {
1211
+ className: "kino-q-px",
1212
+ children: [
1213
+ "(",
1214
+ autoHint,
1215
+ ")"
1216
+ ]
1217
+ })]
1218
+ })
1219
+ }), sorted.map((q) => /* @__PURE__ */ jsx("button", {
1220
+ role: "menuitemradio",
1221
+ "aria-checked": active === q.id,
1222
+ className: "kino-menu-item kino-menu-q",
1223
+ onClick: () => actions.setQuality(q.id),
1224
+ children: /* @__PURE__ */ jsxs("span", {
1225
+ className: "kino-q-label",
1226
+ children: [/* @__PURE__ */ jsx("span", {
1227
+ className: "kino-q-key",
1228
+ children: heightKeyword$1(q.height)
1229
+ }), /* @__PURE__ */ jsxs("span", {
1230
+ className: "kino-q-px",
1231
+ children: [q.height, "p"]
1232
+ })]
1233
+ })
1234
+ }, q.id))]
1235
+ });
1236
+ }
1237
+ function CaptionsMenu() {
1238
+ const actions = usePlayerActions();
1239
+ const tracks = useMediaSelector((s) => s.textTracks);
1240
+ const active = useMediaSelector((s) => s.activeTextTrackId);
1241
+ if (!useMediaSelector((s) => s.capabilities.hasTextTracks) || tracks.length === 0) return null;
1242
+ return /* @__PURE__ */ jsxs(Popover, {
1243
+ label: "Captions",
1244
+ shortcut: "C",
1245
+ align: "end",
1246
+ trigger: active ? /* @__PURE__ */ jsx(CcIcon, {}) : /* @__PURE__ */ jsx(CcOffIcon, {}),
1247
+ children: [/* @__PURE__ */ jsx("button", {
1248
+ role: "menuitemradio",
1249
+ "aria-checked": active === null,
1250
+ className: "kino-menu-item",
1251
+ onClick: () => actions.setTextTrack(null),
1252
+ children: "Off"
1253
+ }), tracks.map((t) => /* @__PURE__ */ jsx("button", {
1254
+ role: "menuitemradio",
1255
+ "aria-checked": active === t.id,
1256
+ className: "kino-menu-item",
1257
+ onClick: () => actions.setTextTrack(t.id),
1258
+ children: t.label || t.lang
1259
+ }, t.id))]
1260
+ });
1261
+ }
1262
+ //#endregion
1263
+ //#region src/ui/settings-sheet.tsx
1264
+ const RATES = [
1265
+ .8,
1266
+ 1,
1267
+ 1.2,
1268
+ 1.5,
1269
+ 1.7,
1270
+ 2,
1271
+ 2.5
1272
+ ];
1273
+ const QUALITY_TIERS = [
1274
+ [2160, "4K"],
1275
+ [1440, "2K"],
1276
+ [1080, "FHD"],
1277
+ [720, "HD"],
1278
+ [480, "SD"]
1279
+ ];
1280
+ const heightKeyword = (h) => {
1281
+ for (const [min, kw] of QUALITY_TIERS) if (h >= min) return kw;
1282
+ return `${h}p`;
1283
+ };
1284
+ function SettingsSheet({ open, onClose }) {
1285
+ const actions = usePlayerActions();
1286
+ const rate = useMediaSelector((s) => s.rate);
1287
+ const canSetRate = useMediaSelector((s) => s.capabilities.canSetRate);
1288
+ const tracks = useMediaSelector((s) => s.textTracks);
1289
+ const activeTrack = useMediaSelector((s) => s.activeTextTrackId);
1290
+ const hasTextTracks = useMediaSelector((s) => s.capabilities.hasTextTracks);
1291
+ const qualities = useMediaSelector((s) => s.qualities);
1292
+ const activeQuality = useMediaSelector((s) => s.activeQualityId);
1293
+ const canSetQuality = useMediaSelector((s) => s.capabilities.canSetQuality);
1294
+ const sortedQualities = [...qualities].sort((a, b) => b.height - a.height);
1295
+ return /* @__PURE__ */ jsx("div", {
1296
+ className: `kino-sheet-backdrop ${open ? "is-open" : ""}`,
1297
+ onClick: onClose,
1298
+ "aria-hidden": !open,
1299
+ children: /* @__PURE__ */ jsxs("div", {
1300
+ className: "kino-sheet kino-glass",
1301
+ role: "dialog",
1302
+ "aria-label": "Settings",
1303
+ "aria-modal": "true",
1304
+ onClick: (e) => e.stopPropagation(),
1305
+ children: [
1306
+ /* @__PURE__ */ jsx("div", {
1307
+ className: "kino-sheet-grip",
1308
+ "aria-hidden": "true"
1309
+ }),
1310
+ canSetRate && /* @__PURE__ */ jsxs("section", {
1311
+ className: "kino-sheet-section",
1312
+ children: [/* @__PURE__ */ jsx("h3", {
1313
+ className: "kino-sheet-title",
1314
+ children: "Speed"
1315
+ }), /* @__PURE__ */ jsx("div", {
1316
+ className: "kino-sheet-chips",
1317
+ children: RATES.map((r) => /* @__PURE__ */ jsx("button", {
1318
+ type: "button",
1319
+ className: "kino-sheet-chip",
1320
+ "data-active": r === rate,
1321
+ "aria-pressed": r === rate,
1322
+ onClick: () => actions.setRate(r),
1323
+ children: r === 2.5 ? "Max" : `${r}×`
1324
+ }, r))
1325
+ })]
1326
+ }),
1327
+ hasTextTracks && tracks.length > 0 && /* @__PURE__ */ jsxs("section", {
1328
+ className: "kino-sheet-section",
1329
+ children: [/* @__PURE__ */ jsx("h3", {
1330
+ className: "kino-sheet-title",
1331
+ children: "Captions"
1332
+ }), /* @__PURE__ */ jsxs("div", {
1333
+ className: "kino-sheet-chips",
1334
+ children: [/* @__PURE__ */ jsx("button", {
1335
+ type: "button",
1336
+ className: "kino-sheet-chip",
1337
+ "data-active": activeTrack === null,
1338
+ "aria-pressed": activeTrack === null,
1339
+ onClick: () => actions.setTextTrack(null),
1340
+ children: "Off"
1341
+ }), tracks.map((t) => /* @__PURE__ */ jsx("button", {
1342
+ type: "button",
1343
+ className: "kino-sheet-chip",
1344
+ "data-active": activeTrack === t.id,
1345
+ "aria-pressed": activeTrack === t.id,
1346
+ onClick: () => actions.setTextTrack(t.id),
1347
+ children: t.label || t.lang
1348
+ }, t.id))]
1349
+ })]
1350
+ }),
1351
+ canSetQuality && qualities.length > 0 && /* @__PURE__ */ jsxs("section", {
1352
+ className: "kino-sheet-section",
1353
+ children: [/* @__PURE__ */ jsx("h3", {
1354
+ className: "kino-sheet-title",
1355
+ children: "Quality"
1356
+ }), /* @__PURE__ */ jsxs("div", {
1357
+ className: "kino-sheet-chips",
1358
+ children: [/* @__PURE__ */ jsx("button", {
1359
+ type: "button",
1360
+ className: "kino-sheet-chip",
1361
+ "data-active": activeQuality === "auto",
1362
+ "aria-pressed": activeQuality === "auto",
1363
+ onClick: () => actions.setQuality("auto"),
1364
+ children: "Auto"
1365
+ }), sortedQualities.map((q) => /* @__PURE__ */ jsx("button", {
1366
+ type: "button",
1367
+ className: "kino-sheet-chip",
1368
+ "data-active": activeQuality === q.id,
1369
+ "aria-pressed": activeQuality === q.id,
1370
+ onClick: () => actions.setQuality(q.id),
1371
+ children: heightKeyword(q.height)
1372
+ }, q.id))]
1373
+ })]
1374
+ })
1375
+ ]
1376
+ })
1377
+ });
1378
+ }
1379
+ //#endregion
1380
+ //#region src/ui/mobile-controls.tsx
1381
+ const SKIP_SECONDS = 5;
1382
+ function MobileControls() {
1383
+ const visible = useControlsVisible();
1384
+ const actions = usePlayerActions();
1385
+ const paused = useMediaSelector((s) => s.paused);
1386
+ const currentTime = useMediaSelector((s) => s.currentTime);
1387
+ const duration = useMediaSelector((s) => s.duration);
1388
+ const ended = useMediaSelector((s) => s.ended);
1389
+ const fullscreen = useMediaSelector((s) => s.fullscreen);
1390
+ const canFullscreen = useMediaSelector((s) => s.capabilities.canFullscreen);
1391
+ const wrapperRef = useWrapperRef();
1392
+ const [sheetOpen, setSheetOpen] = useState(false);
1393
+ if (paused && currentTime === 0 && !ended) return null;
1394
+ const back = () => actions.seek(Math.max(0, currentTime - SKIP_SECONDS));
1395
+ const forward = () => actions.seek(duration ? Math.min(duration, currentTime + SKIP_SECONDS) : currentTime + SKIP_SECONDS);
1396
+ const toggleFullscreen = () => {
1397
+ if (fullscreen) actions.exitFullscreen();
1398
+ else if (wrapperRef?.current) actions.enterFullscreen(wrapperRef.current);
1399
+ };
1400
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
1401
+ className: `kino-mcontrols ${visible ? "is-visible" : ""}`,
1402
+ children: [
1403
+ /* @__PURE__ */ jsx("div", {
1404
+ className: "kino-mtop",
1405
+ children: /* @__PURE__ */ jsx("button", {
1406
+ type: "button",
1407
+ className: "kino-mbtn",
1408
+ "aria-label": "Settings",
1409
+ onClick: () => setSheetOpen(true),
1410
+ children: /* @__PURE__ */ jsx(SettingsIcon, {})
1411
+ })
1412
+ }),
1413
+ /* @__PURE__ */ jsxs("div", {
1414
+ className: "kino-mcluster",
1415
+ children: [
1416
+ /* @__PURE__ */ jsx("button", {
1417
+ type: "button",
1418
+ className: "kino-mtransport",
1419
+ "aria-label": `Back ${SKIP_SECONDS} seconds`,
1420
+ onClick: back,
1421
+ children: /* @__PURE__ */ jsx(SkipBack5Icon, {})
1422
+ }),
1423
+ /* @__PURE__ */ jsx("button", {
1424
+ type: "button",
1425
+ className: "kino-mctrl-play",
1426
+ "aria-label": paused ? "Play" : "Pause",
1427
+ onClick: () => paused ? actions.play() : actions.pause(),
1428
+ children: paused ? /* @__PURE__ */ jsx(PlayIcon, {}) : /* @__PURE__ */ jsx(PauseIcon, {})
1429
+ }),
1430
+ /* @__PURE__ */ jsx("button", {
1431
+ type: "button",
1432
+ className: "kino-mtransport",
1433
+ "aria-label": `Forward ${SKIP_SECONDS} seconds`,
1434
+ onClick: forward,
1435
+ children: /* @__PURE__ */ jsx(SkipForward5Icon, {})
1436
+ })
1437
+ ]
1438
+ }),
1439
+ /* @__PURE__ */ jsxs("div", {
1440
+ className: "kino-mbottom",
1441
+ children: [
1442
+ /* @__PURE__ */ jsx("span", {
1443
+ className: "kino-time kino-mtime",
1444
+ children: formatTime(currentTime)
1445
+ }),
1446
+ /* @__PURE__ */ jsx(Scrubber, {}),
1447
+ /* @__PURE__ */ jsx("span", {
1448
+ className: "kino-time kino-mtime kino-time-dur",
1449
+ children: formatTime(duration)
1450
+ }),
1451
+ canFullscreen && /* @__PURE__ */ jsx("button", {
1452
+ type: "button",
1453
+ className: "kino-mbtn",
1454
+ "aria-label": fullscreen ? "Exit fullscreen" : "Fullscreen",
1455
+ onClick: toggleFullscreen,
1456
+ children: fullscreen ? /* @__PURE__ */ jsx(FullscreenExitIcon, {}) : /* @__PURE__ */ jsx(FullscreenIcon, {})
1457
+ })
1458
+ ]
1459
+ })
1460
+ ]
1461
+ }), /* @__PURE__ */ jsx(SettingsSheet, {
1462
+ open: sheetOpen,
1463
+ onClose: () => setSheetOpen(false)
1464
+ })] });
1465
+ }
1466
+ //#endregion
1467
+ //#region src/ui/control-bar.tsx
1468
+ function ControlBar() {
1469
+ return useIsCompact() ? /* @__PURE__ */ jsx(MobileControls, {}) : /* @__PURE__ */ jsx(DesktopControls, {});
1470
+ }
1471
+ function DesktopControls() {
1472
+ const visible = useControlsVisible();
1473
+ const currentTime = useMediaSelector((s) => s.currentTime);
1474
+ const duration = useMediaSelector((s) => s.duration);
1475
+ const ended = useMediaSelector((s) => s.ended);
1476
+ if (useMediaSelector((s) => s.paused) && currentTime === 0 && !ended) return null;
1477
+ return /* @__PURE__ */ jsxs("div", {
1478
+ className: `kino-controls ${visible ? "is-visible" : ""}`,
1479
+ children: [/* @__PURE__ */ jsx(Scrubber, {}), /* @__PURE__ */ jsxs("div", {
1480
+ className: "kino-controls-row",
1481
+ children: [
1482
+ /* @__PURE__ */ jsxs("div", {
1483
+ className: "kino-controls-group",
1484
+ children: [
1485
+ /* @__PURE__ */ jsx(PlayPauseButton, {}),
1486
+ /* @__PURE__ */ jsx(SkipBackButton, {}),
1487
+ /* @__PURE__ */ jsx(SkipForwardButton, {}),
1488
+ /* @__PURE__ */ jsx(VolumeControl, {})
1489
+ ]
1490
+ }),
1491
+ /* @__PURE__ */ jsxs("span", {
1492
+ className: "kino-time",
1493
+ children: [formatTime(currentTime), /* @__PURE__ */ jsxs("span", {
1494
+ className: "kino-time-dur",
1495
+ children: [" / ", formatTime(duration)]
1496
+ })]
1497
+ }),
1498
+ /* @__PURE__ */ jsxs("div", {
1499
+ className: "kino-controls-group kino-controls-group-end",
1500
+ children: [
1501
+ /* @__PURE__ */ jsx(SpeedMenu, {}),
1502
+ /* @__PURE__ */ jsx(QualityMenu, {}),
1503
+ /* @__PURE__ */ jsx(CaptionsMenu, {}),
1504
+ /* @__PURE__ */ jsx(PipButton, {}),
1505
+ /* @__PURE__ */ jsx(FullscreenButton, {})
1506
+ ]
1507
+ })
1508
+ ]
1509
+ })]
1510
+ });
1511
+ }
1512
+ //#endregion
1513
+ export { usePlayer as _, SkipBackButton as a, Captions as c, Player as d, useControlsVisible as f, useMediaSelector as g, PlayerContext as h, PlayPauseButton as i, IdleOverlay as l, useWrapperRef as m, FullscreenButton as n, SkipForwardButton as o, useIsCompact as p, PipButton as r, VolumeControl as s, ControlBar as t, Scrubber as u, usePlayerActions as v, formatTime as y };