@hogoshi/ui 0.0.0-stage → 0.1.1

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 (57) hide show
  1. package/package.json +63 -4
  2. package/src/components/avatar.tsx +30 -0
  3. package/src/components/badge.tsx +26 -0
  4. package/src/components/breadcrumb.tsx +37 -0
  5. package/src/components/button.tsx +58 -0
  6. package/src/components/calendar.tsx +244 -0
  7. package/src/components/card.tsx +18 -0
  8. package/src/components/chart.tsx +387 -0
  9. package/src/components/checkbox.tsx +26 -0
  10. package/src/components/code-block.tsx +118 -0
  11. package/src/components/collapsible.tsx +31 -0
  12. package/src/components/command.tsx +111 -0
  13. package/src/components/context-menu.tsx +150 -0
  14. package/src/components/counter.tsx +56 -0
  15. package/src/components/dialog.tsx +36 -0
  16. package/src/components/dropdown-menu.tsx +54 -0
  17. package/src/components/file-upload.tsx +381 -0
  18. package/src/components/glide.tsx +82 -0
  19. package/src/components/icon.tsx +28 -0
  20. package/src/components/image-viewer.tsx +370 -0
  21. package/src/components/input.tsx +187 -0
  22. package/src/components/kbd.tsx +14 -0
  23. package/src/components/logo-mark.ts +8 -0
  24. package/src/components/logo.tsx +36 -0
  25. package/src/components/navbar.tsx +144 -0
  26. package/src/components/otp.tsx +93 -0
  27. package/src/components/page-loader.tsx +37 -0
  28. package/src/components/pophover.tsx +28 -0
  29. package/src/components/popover.tsx +36 -0
  30. package/src/components/popup.tsx +75 -0
  31. package/src/components/progress.tsx +26 -0
  32. package/src/components/result.tsx +22 -0
  33. package/src/components/scroll-area.tsx +38 -0
  34. package/src/components/select.tsx +52 -0
  35. package/src/components/separator.tsx +14 -0
  36. package/src/components/sheet.tsx +96 -0
  37. package/src/components/sidebar.tsx +195 -0
  38. package/src/components/skeleton.tsx +14 -0
  39. package/src/components/slider.tsx +31 -0
  40. package/src/components/smooth-scroll.tsx +174 -0
  41. package/src/components/spinner.tsx +18 -0
  42. package/src/components/switch.tsx +22 -0
  43. package/src/components/table.tsx +52 -0
  44. package/src/components/tabs.tsx +48 -0
  45. package/src/components/theme-toggle.tsx +214 -0
  46. package/src/components/toaster.tsx +38 -0
  47. package/src/components/tooltip.tsx +43 -0
  48. package/src/components/video-player.tsx +559 -0
  49. package/src/components/video-settings.tsx +233 -0
  50. package/src/index.ts +102 -0
  51. package/src/lib/cn.ts +6 -0
  52. package/src/lib/jsx-highlight.ts +128 -0
  53. package/src/styles.css +443 -0
  54. package/src/theme/theme-script.ts +13 -0
  55. package/src/theme/theme.tsx +77 -0
  56. package/styles.css +1 -0
  57. package/README.md +0 -3
@@ -0,0 +1,559 @@
1
+ "use client";
2
+
3
+ import {
4
+ Alert02Icon,
5
+ FullScreenIcon,
6
+ MinimizeScreenIcon,
7
+ PauseIcon,
8
+ PictureInPictureOnIcon,
9
+ PlayIcon,
10
+ RepeatIcon,
11
+ Settings02Icon,
12
+ VolumeHighIcon,
13
+ VolumeLowIcon,
14
+ VolumeMute02Icon,
15
+ } from "@hugeicons-pro/core-solid-rounded";
16
+ import {
17
+ useCallback,
18
+ useEffect,
19
+ useRef,
20
+ useState,
21
+ type KeyboardEvent,
22
+ type PointerEvent as ReactPointerEvent,
23
+ type RefObject,
24
+ } from "react";
25
+ import { cn } from "../lib/cn";
26
+ import { Icon, type AppIcon } from "./icon";
27
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
28
+ import { Slider } from "./slider";
29
+ import { Spinner } from "./spinner";
30
+ import { SPEEDS, VideoSettings, type SleepTimer, type VideoFit } from "./video-settings";
31
+
32
+ export interface VideoSource {
33
+ src: string;
34
+ label: string;
35
+ type?: string;
36
+ }
37
+
38
+ export interface VideoPlayerProps {
39
+ sources: VideoSource[];
40
+ poster?: string;
41
+ title?: string;
42
+ autoPlay?: boolean;
43
+ loop?: boolean;
44
+ preview?: boolean;
45
+ className?: string;
46
+ }
47
+
48
+ const STORAGE = "hogoshi-player";
49
+ const FRAME = 1 / 30;
50
+ const IDLE_MS = 2600;
51
+
52
+ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
53
+
54
+ export function formatTime(seconds: number) {
55
+ if (!Number.isFinite(seconds)) return "0:00";
56
+ const total = Math.max(0, Math.floor(seconds));
57
+ const h = Math.floor(total / 3600);
58
+ const m = Math.floor((total % 3600) / 60);
59
+ const s = String(total % 60).padStart(2, "0");
60
+ return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
61
+ }
62
+
63
+ const controlClass =
64
+ "text-fg hover:bg-fg/10 inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full outline-none transition-colors duration-[var(--motion-duration)] focus-visible:ring-2 focus-visible:ring-ring";
65
+
66
+ function Control({ label, icon, onClick }: { label: string; icon: AppIcon; onClick: () => void }) {
67
+ return (
68
+ <button type="button" aria-label={label} className={controlClass} onClick={onClick}>
69
+ <Icon icon={icon} size={20} />
70
+ </button>
71
+ );
72
+ }
73
+
74
+ function readStored() {
75
+ try {
76
+ return JSON.parse(localStorage.getItem(STORAGE) ?? "") as { volume: number; muted: boolean };
77
+ } catch {
78
+ return { volume: 1, muted: false };
79
+ }
80
+ }
81
+
82
+ function useTimeline(video: RefObject<HTMLVideoElement | null>, duration: number) {
83
+ const track = useRef<HTMLDivElement>(null);
84
+ const preview = useRef<HTMLVideoElement>(null);
85
+ const pending = useRef<number | null>(null);
86
+ const busy = useRef(false);
87
+ const resume = useRef(false);
88
+ const [hover, setHover] = useState<{ time: number; x: number; width: number } | null>(null);
89
+ const [hovering, setHovering] = useState(false);
90
+ const [scrubbing, setScrubbing] = useState(false);
91
+
92
+ const measure = (clientX: number) => {
93
+ const rect = track.current?.getBoundingClientRect();
94
+ if (!rect || !duration) return null;
95
+ const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
96
+ return { time: ratio * duration, x: ratio * rect.width, width: rect.width };
97
+ };
98
+
99
+ const seekPreview = (time: number) => {
100
+ const node = preview.current;
101
+ if (!node) return;
102
+ if (busy.current) {
103
+ pending.current = time;
104
+ return;
105
+ }
106
+ busy.current = true;
107
+ node.currentTime = time;
108
+ };
109
+
110
+ const onPreviewSeeked = () => {
111
+ busy.current = false;
112
+ if (pending.current === null) return;
113
+ const next = pending.current;
114
+ pending.current = null;
115
+ seekPreview(next);
116
+ };
117
+
118
+ const seekTo = (clientX: number) => {
119
+ const point = measure(clientX);
120
+ if (point && video.current) video.current.currentTime = point.time;
121
+ };
122
+
123
+ const handlers = {
124
+ onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => {
125
+ const point = measure(event.clientX);
126
+ if (!point) return;
127
+ setHover(point);
128
+ setHovering(true);
129
+ seekPreview(point.time);
130
+ if (scrubbing) seekTo(event.clientX);
131
+ },
132
+ onPointerLeave: () => setHovering(false),
133
+ onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => {
134
+ event.currentTarget.setPointerCapture(event.pointerId);
135
+ const node = video.current;
136
+ resume.current = Boolean(node && !node.paused);
137
+ node?.pause();
138
+ setScrubbing(true);
139
+ seekTo(event.clientX);
140
+ },
141
+ onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => {
142
+ setScrubbing(false);
143
+ if (event.pointerType !== "mouse") setHovering(false);
144
+ if (resume.current) void video.current?.play();
145
+ },
146
+ };
147
+
148
+ return { track, preview, hover, hovering, scrubbing, handlers, onPreviewSeeked };
149
+ }
150
+
151
+ export function VideoPlayer({
152
+ sources,
153
+ poster,
154
+ title,
155
+ autoPlay = false,
156
+ loop: initialLoop = false,
157
+ preview: showPreview = true,
158
+ className,
159
+ }: VideoPlayerProps) {
160
+ const root = useRef<HTMLDivElement>(null);
161
+ const video = useRef<HTMLVideoElement>(null);
162
+ const idle = useRef<ReturnType<typeof setTimeout>>(undefined);
163
+ const restore = useRef<{ time: number; playing: boolean } | null>(null);
164
+ const [source, setSource] = useState(0);
165
+ const [playing, setPlaying] = useState(false);
166
+ const [current, setCurrent] = useState(0);
167
+ const [duration, setDuration] = useState(0);
168
+ const [buffered, setBuffered] = useState<[number, number][]>([]);
169
+ const [volume, setVolume] = useState(1);
170
+ const [muted, setMuted] = useState(false);
171
+ const [rate, setRate] = useState(1);
172
+ const [loop, setLoop] = useState(initialLoop);
173
+ const [sleep, setSleep] = useState<SleepTimer>(null);
174
+ const [fit, setFit] = useState<VideoFit>("contain");
175
+ const [waiting, setWaiting] = useState(false);
176
+ const [ended, setEnded] = useState(false);
177
+ const [failed, setFailed] = useState(false);
178
+ const [fullscreen, setFullscreen] = useState(false);
179
+ const [active, setActive] = useState(true);
180
+ const [settings, setSettings] = useState(false);
181
+ const [hint, setHint] = useState<{ id: number; text: string } | null>(null);
182
+ const [flash, setFlash] = useState<{ id: number; icon: AppIcon } | null>(null);
183
+ const timeline = useTimeline(video, duration);
184
+
185
+ const src = sources[source]?.src ?? sources[0]?.src;
186
+ const visible = active || !playing || settings || timeline.scrubbing || failed;
187
+
188
+ useEffect(() => {
189
+ const stored = readStored();
190
+ setVolume(clamp(stored.volume, 0, 1));
191
+ setMuted(stored.muted);
192
+ }, []);
193
+
194
+ useEffect(() => {
195
+ const node = video.current;
196
+ if (!node) return;
197
+ node.volume = volume;
198
+ node.muted = muted;
199
+ localStorage.setItem(STORAGE, JSON.stringify({ volume, muted }));
200
+ }, [volume, muted]);
201
+
202
+ useEffect(() => {
203
+ if (video.current) video.current.playbackRate = rate;
204
+ }, [rate, source]);
205
+
206
+ useEffect(() => {
207
+ if (!playing) return;
208
+ let frame = 0;
209
+ const tick = () => {
210
+ if (video.current) setCurrent(video.current.currentTime);
211
+ frame = requestAnimationFrame(tick);
212
+ };
213
+ frame = requestAnimationFrame(tick);
214
+ return () => cancelAnimationFrame(frame);
215
+ }, [playing]);
216
+
217
+ useEffect(() => {
218
+ const sync = () => setFullscreen(document.fullscreenElement === root.current);
219
+ document.addEventListener("fullscreenchange", sync);
220
+ return () => document.removeEventListener("fullscreenchange", sync);
221
+ }, []);
222
+
223
+ useEffect(() => () => clearTimeout(idle.current), []);
224
+
225
+ useEffect(() => {
226
+ if (typeof sleep !== "number") return;
227
+ const timer = setTimeout(() => {
228
+ video.current?.pause();
229
+ setSleep(null);
230
+ }, sleep * 60_000);
231
+ return () => clearTimeout(timer);
232
+ }, [sleep]);
233
+
234
+ const poke = useCallback(() => {
235
+ setActive(true);
236
+ clearTimeout(idle.current);
237
+ idle.current = setTimeout(() => setActive(false), IDLE_MS);
238
+ }, []);
239
+
240
+ const say = (text: string) => setHint({ id: Date.now(), text });
241
+
242
+ const toggle = () => {
243
+ const node = video.current;
244
+ if (!node || failed) return;
245
+ const next = node.paused || node.ended;
246
+ if (next) void node.play();
247
+ else node.pause();
248
+ setFlash({ id: Date.now(), icon: next ? PlayIcon : PauseIcon });
249
+ };
250
+
251
+ const seekBy = (seconds: number, label = `${seconds > 0 ? "+" : "−"}${Math.abs(seconds)}s`) => {
252
+ const node = video.current;
253
+ if (!node) return;
254
+ node.currentTime = clamp(node.currentTime + seconds, 0, node.duration || 0);
255
+ setCurrent(node.currentTime);
256
+ say(label);
257
+ };
258
+
259
+ const changeVolume = (next: number) => {
260
+ const value = clamp(Math.round(next * 100) / 100, 0, 1);
261
+ setVolume(value);
262
+ setMuted(value === 0);
263
+ say(`${Math.round(value * 100)}%`);
264
+ };
265
+
266
+ const changeRate = (next: number) => {
267
+ setRate(next);
268
+ say(`${next}×`);
269
+ };
270
+
271
+ const toggleFullscreen = () => {
272
+ const node = root.current as (HTMLDivElement & { webkitRequestFullscreen?: () => void }) | null;
273
+ const media = video.current as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null;
274
+ if (document.fullscreenElement) void document.exitFullscreen();
275
+ else if (node?.requestFullscreen) void node.requestFullscreen();
276
+ else media?.webkitEnterFullscreen?.();
277
+ };
278
+
279
+ const togglePip = () => {
280
+ const node = video.current;
281
+ if (!node || !document.pictureInPictureEnabled) return;
282
+ if (document.pictureInPictureElement) void document.exitPictureInPicture();
283
+ else void node.requestPictureInPicture();
284
+ };
285
+
286
+ const changeSource = (label: string) => {
287
+ const index = sources.findIndex((item) => item.label === label);
288
+ const node = video.current;
289
+ if (index < 0 || index === source || !node) return;
290
+ restore.current = { time: node.currentTime, playing: !node.paused };
291
+ setSource(index);
292
+ };
293
+
294
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
295
+ const target = event.target as HTMLElement;
296
+ if (event.metaKey || event.ctrlKey || event.altKey || target.closest("input, [role=slider], [role=dialog]")) return;
297
+ if (target instanceof HTMLButtonElement && (event.key === " " || event.key === "Enter")) return;
298
+ const node = video.current;
299
+ if (!node) return;
300
+ const key = event.key.toLowerCase();
301
+ const actions: Record<string, () => void> = {
302
+ " ": toggle,
303
+ k: toggle,
304
+ f: toggleFullscreen,
305
+ m: () => setMuted((value) => !value),
306
+ i: togglePip,
307
+ arrowleft: () => seekBy(-5),
308
+ arrowright: () => seekBy(5),
309
+ j: () => seekBy(-10),
310
+ l: () => seekBy(10),
311
+ arrowup: () => changeVolume(volume + 0.05),
312
+ arrowdown: () => changeVolume(volume - 0.05),
313
+ home: () => (node.currentTime = 0),
314
+ end: () => (node.currentTime = node.duration),
315
+ ",": () => node.paused && seekBy(-FRAME, "−1 frame"),
316
+ ".": () => node.paused && seekBy(FRAME, "+1 frame"),
317
+ "<": () => changeRate(SPEEDS[Math.max(0, SPEEDS.indexOf(rate) - 1)]),
318
+ ">": () => changeRate(SPEEDS[Math.min(SPEEDS.length - 1, SPEEDS.indexOf(rate) + 1)]),
319
+ };
320
+ const action = /^[0-9]$/.test(key) ? () => (node.currentTime = (Number(key) / 10) * node.duration) : actions[key];
321
+ if (!action) return;
322
+ event.preventDefault();
323
+ action();
324
+ poke();
325
+ };
326
+
327
+ const volumeIcon = muted || volume === 0 ? VolumeMute02Icon : volume < 0.5 ? VolumeLowIcon : VolumeHighIcon;
328
+ const played = duration ? (current / duration) * 100 : 0;
329
+ const hover = timeline.hover;
330
+
331
+ return (
332
+ <div
333
+ ref={root}
334
+ data-mode="dark"
335
+ tabIndex={0}
336
+ aria-label={title ?? "Video player"}
337
+ className={cn(
338
+ "bg-bg text-fg group/player relative isolate aspect-video w-full overflow-hidden rounded-[var(--radius-lg)] outline-none select-none focus-visible:ring-2 focus-visible:ring-ring",
339
+ fullscreen && "rounded-none",
340
+ !visible && "cursor-none",
341
+ className,
342
+ )}
343
+ onPointerMove={poke}
344
+ onPointerLeave={() => playing && setActive(false)}
345
+ onKeyDown={onKeyDown}
346
+ >
347
+ <video
348
+ ref={video}
349
+ src={src}
350
+ poster={poster}
351
+ autoPlay={autoPlay}
352
+ loop={loop && sleep !== "end"}
353
+ playsInline
354
+ preload="metadata"
355
+ className={cn("size-full", fit === "cover" ? "object-cover" : "object-contain")}
356
+ onClick={toggle}
357
+ onDoubleClick={toggleFullscreen}
358
+ onPlay={() => {
359
+ setPlaying(true);
360
+ setEnded(false);
361
+ poke();
362
+ }}
363
+ onPause={() => setPlaying(false)}
364
+ onEnded={() => {
365
+ setEnded(true);
366
+ if (sleep === "end") setSleep(null);
367
+ }}
368
+ onWaiting={() => setWaiting(true)}
369
+ onPlaying={() => setWaiting(false)}
370
+ onCanPlay={() => setWaiting(false)}
371
+ onError={() => setFailed(true)}
372
+ onTimeUpdate={(event) => setCurrent(event.currentTarget.currentTime)}
373
+ onDurationChange={(event) => setDuration(event.currentTarget.duration)}
374
+ onProgress={(event) => {
375
+ const ranges = event.currentTarget.buffered;
376
+ setBuffered(Array.from({ length: ranges.length }, (_, at) => [ranges.start(at), ranges.end(at)]));
377
+ }}
378
+ onLoadedMetadata={(event) => {
379
+ const node = event.currentTarget;
380
+ setFailed(false);
381
+ node.volume = volume;
382
+ node.muted = muted;
383
+ node.playbackRate = rate;
384
+ if (!restore.current) return;
385
+ node.currentTime = restore.current.time;
386
+ if (restore.current.playing) void node.play();
387
+ restore.current = null;
388
+ }}
389
+ />
390
+
391
+ {waiting && !failed ? (
392
+ <div className="pointer-events-none absolute inset-0 grid place-items-center">
393
+ <Spinner size="lg" className="bg-overlay rounded-full p-3" />
394
+ </div>
395
+ ) : null}
396
+
397
+ {failed ? (
398
+ <div className="bg-overlay absolute inset-0 grid place-items-center gap-2 text-center">
399
+ <div className="grid justify-items-center gap-2">
400
+ <Icon icon={Alert02Icon} size={28} className="text-danger" />
401
+ <p className="text-sm">This video can’t be played</p>
402
+ </div>
403
+ </div>
404
+ ) : null}
405
+
406
+ {flash ? (
407
+ <div key={flash.id} className="pointer-events-none absolute inset-0 grid place-items-center">
408
+ <span className="bg-overlay grid size-16 animate-[player-flash_600ms_var(--motion-ease)_forwards] place-items-center rounded-full">
409
+ <Icon icon={flash.icon} size={28} />
410
+ </span>
411
+ </div>
412
+ ) : null}
413
+
414
+ {hint ? (
415
+ <p
416
+ key={hint.id}
417
+ className="bg-overlay pointer-events-none absolute top-[12%] left-1/2 -translate-x-1/2 animate-[player-flash_900ms_ease-out_forwards] rounded-full px-3 py-1 text-sm font-medium tabular-nums"
418
+ >
419
+ {hint.text}
420
+ </p>
421
+ ) : null}
422
+
423
+ {title ? (
424
+ <div
425
+ className={cn(
426
+ "from-overlay pointer-events-none absolute inset-x-0 top-0 bg-gradient-to-b to-transparent px-4 pt-3 pb-10 transition-opacity duration-[var(--motion-duration)]",
427
+ visible ? "opacity-100" : "opacity-0",
428
+ )}
429
+ >
430
+ <p className="truncate text-sm font-medium">{title}</p>
431
+ </div>
432
+ ) : null}
433
+
434
+ <div
435
+ className={cn(
436
+ "from-overlay absolute inset-x-0 bottom-0 bg-gradient-to-t to-transparent px-3 pt-12 pb-[calc(0.25rem+var(--safe-bottom))] transition-opacity duration-[var(--motion-duration)] ease-[var(--motion-ease)]",
437
+ visible ? "opacity-100" : "pointer-events-none opacity-0",
438
+ )}
439
+ >
440
+ <div
441
+ ref={timeline.track}
442
+ role="slider"
443
+ aria-label="Seek"
444
+ aria-valuemin={0}
445
+ aria-valuemax={Math.round(duration)}
446
+ aria-valuenow={Math.round(current)}
447
+ aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
448
+ className="group/timeline relative flex h-4 cursor-pointer touch-none items-center"
449
+ {...timeline.handlers}
450
+ >
451
+ <div className="bg-fg/20 relative h-1 w-full overflow-hidden rounded-full transition-[height] duration-[var(--motion-duration)] group-hover/timeline:h-1.5">
452
+ {buffered.map(([start, end]) => (
453
+ <span
454
+ key={start}
455
+ className="bg-fg/30 absolute inset-y-0"
456
+ style={{ left: `${(start / duration) * 100}%`, width: `${((end - start) / duration) * 100}%` }}
457
+ />
458
+ ))}
459
+ {hover && timeline.hovering ? <span className="bg-fg/25 absolute inset-y-0 left-0" style={{ width: hover.x }} /> : null}
460
+ <span className="bg-accent absolute inset-y-0 left-0" style={{ width: `${played}%` }} />
461
+ </div>
462
+ <span
463
+ className={cn(
464
+ "bg-accent pointer-events-none absolute size-3.5 -translate-x-1/2 rounded-full transition-transform duration-[var(--motion-duration)] group-hover/timeline:scale-100",
465
+ timeline.scrubbing ? "scale-100" : "scale-0",
466
+ )}
467
+ style={{ left: `${played}%` }}
468
+ />
469
+ <div
470
+ className={cn(
471
+ "pointer-events-none absolute bottom-6 grid -translate-x-1/2 justify-items-center gap-1 transition-opacity duration-[var(--motion-duration)]",
472
+ timeline.hovering || timeline.scrubbing ? "opacity-100" : "opacity-0",
473
+ )}
474
+ style={{ left: hover ? clamp(hover.x, showPreview ? 88 : 24, hover.width - (showPreview ? 88 : 24)) : 0 }}
475
+ >
476
+ {showPreview ? (
477
+ <video
478
+ ref={timeline.preview}
479
+ src={sources.at(-1)?.src}
480
+ muted
481
+ playsInline
482
+ preload="metadata"
483
+ tabIndex={-1}
484
+ aria-hidden
485
+ className="border-fg/20 bg-bg aspect-video w-44 rounded-[var(--radius-md)] border object-cover shadow-[var(--shadow-md)]"
486
+ onSeeked={timeline.onPreviewSeeked}
487
+ />
488
+ ) : null}
489
+ <span className="bg-overlay rounded-full px-2 py-0.5 text-xs font-medium tabular-nums">
490
+ {formatTime(hover?.time ?? 0)}
491
+ </span>
492
+ </div>
493
+ </div>
494
+
495
+ <div className="flex items-center gap-0.5">
496
+ <Control label={ended ? "Replay" : playing ? "Pause" : "Play"} icon={ended ? RepeatIcon : playing ? PauseIcon : PlayIcon} onClick={toggle} />
497
+ <div className="group/volume flex items-center">
498
+ <Control label={muted ? "Unmute" : "Mute"} icon={volumeIcon} onClick={() => setMuted((value) => !value)} />
499
+ <div className="w-0 overflow-hidden transition-[width] duration-[var(--motion-duration)] ease-[var(--motion-ease)] group-focus-within/volume:w-20 group-hover/volume:w-20">
500
+ <Slider
501
+ aria-label="Volume"
502
+ className="w-20 px-2.5"
503
+ min={0}
504
+ max={1}
505
+ step={0.01}
506
+ value={muted ? 0 : volume}
507
+ onValueChange={(value) => {
508
+ setVolume(value);
509
+ setMuted(value === 0);
510
+ }}
511
+ />
512
+ </div>
513
+ </div>
514
+ <p className="text-fg-muted px-2 text-xs tabular-nums">
515
+ <span className="text-fg">{formatTime(current)}</span> / {formatTime(duration)}
516
+ </p>
517
+ <span className="flex-1" />
518
+ {rate !== 1 ? <span className="text-fg-muted text-xs tabular-nums">{rate}×</span> : null}
519
+ <Popover open={settings} onOpenChange={setSettings}>
520
+ <PopoverTrigger aria-label="Settings" className={controlClass}>
521
+ <Icon
522
+ icon={Settings02Icon}
523
+ size={20}
524
+ className={cn("transition-transform duration-[var(--motion-duration)]", settings && "rotate-45")}
525
+ />
526
+ </PopoverTrigger>
527
+ <PopoverContent
528
+ data-mode="dark"
529
+ container={fullscreen ? root : undefined}
530
+ side="top"
531
+ align="end"
532
+ className="w-72 p-1"
533
+ >
534
+ <VideoSettings
535
+ rate={rate}
536
+ onRate={changeRate}
537
+ qualities={sources.map((item) => item.label)}
538
+ quality={sources[source]?.label ?? ""}
539
+ onQuality={changeSource}
540
+ sleep={sleep}
541
+ onSleep={setSleep}
542
+ fit={fit}
543
+ onFit={setFit}
544
+ loop={loop}
545
+ onLoop={setLoop}
546
+ />
547
+ </PopoverContent>
548
+ </Popover>
549
+ <Control label="Picture in picture" icon={PictureInPictureOnIcon} onClick={togglePip} />
550
+ <Control
551
+ label={fullscreen ? "Exit fullscreen" : "Fullscreen"}
552
+ icon={fullscreen ? MinimizeScreenIcon : FullScreenIcon}
553
+ onClick={toggleFullscreen}
554
+ />
555
+ </div>
556
+ </div>
557
+ </div>
558
+ );
559
+ }