@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.
- package/package.json +63 -4
- package/src/components/avatar.tsx +30 -0
- package/src/components/badge.tsx +26 -0
- package/src/components/breadcrumb.tsx +37 -0
- package/src/components/button.tsx +58 -0
- package/src/components/calendar.tsx +244 -0
- package/src/components/card.tsx +18 -0
- package/src/components/chart.tsx +387 -0
- package/src/components/checkbox.tsx +26 -0
- package/src/components/code-block.tsx +118 -0
- package/src/components/collapsible.tsx +31 -0
- package/src/components/command.tsx +111 -0
- package/src/components/context-menu.tsx +150 -0
- package/src/components/counter.tsx +56 -0
- package/src/components/dialog.tsx +36 -0
- package/src/components/dropdown-menu.tsx +54 -0
- package/src/components/file-upload.tsx +381 -0
- package/src/components/glide.tsx +82 -0
- package/src/components/icon.tsx +28 -0
- package/src/components/image-viewer.tsx +370 -0
- package/src/components/input.tsx +187 -0
- package/src/components/kbd.tsx +14 -0
- package/src/components/logo-mark.ts +8 -0
- package/src/components/logo.tsx +36 -0
- package/src/components/navbar.tsx +144 -0
- package/src/components/otp.tsx +93 -0
- package/src/components/page-loader.tsx +37 -0
- package/src/components/pophover.tsx +28 -0
- package/src/components/popover.tsx +36 -0
- package/src/components/popup.tsx +75 -0
- package/src/components/progress.tsx +26 -0
- package/src/components/result.tsx +22 -0
- package/src/components/scroll-area.tsx +38 -0
- package/src/components/select.tsx +52 -0
- package/src/components/separator.tsx +14 -0
- package/src/components/sheet.tsx +96 -0
- package/src/components/sidebar.tsx +195 -0
- package/src/components/skeleton.tsx +14 -0
- package/src/components/slider.tsx +31 -0
- package/src/components/smooth-scroll.tsx +174 -0
- package/src/components/spinner.tsx +18 -0
- package/src/components/switch.tsx +22 -0
- package/src/components/table.tsx +52 -0
- package/src/components/tabs.tsx +48 -0
- package/src/components/theme-toggle.tsx +214 -0
- package/src/components/toaster.tsx +38 -0
- package/src/components/tooltip.tsx +43 -0
- package/src/components/video-player.tsx +559 -0
- package/src/components/video-settings.tsx +233 -0
- package/src/index.ts +102 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/jsx-highlight.ts +128 -0
- package/src/styles.css +443 -0
- package/src/theme/theme-script.ts +13 -0
- package/src/theme/theme.tsx +77 -0
- package/styles.css +1 -0
- 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
|
+
}
|