返回 JoyAI-Echo
ShotVideoPlayer.tsx
1 import {
2 useCallback,
3 useEffect,
4 useRef,
5 useState,
6 type PointerEvent as ReactPointerEvent,
7 } from "react";
8 import {
9 Maximize,
10 Minimize,
11 Pause,
12 Play,
13 Volume2,
14 VolumeX,
15 } from "lucide-react";
16
17 import { cn } from "@/lib/utils";
18
19 interface ShotVideoPlayerProps {
20 src: string;
21 className?: string;
22 }
23
24 function formatTime(seconds: number): string {
25 const total = Math.round(seconds);
26 const m = Math.floor(total / 60);
27 const sec = total % 60;
28 return `${m}:${sec.toString().padStart(2, "0")}`;
29 }
30
31 /** ShotCard overlay video player: play/pause, muted, fullscreen, draggable scrubber. */
32 export function ShotVideoPlayer({ src, className }: ShotVideoPlayerProps) {
33 const containerRef = useRef<HTMLDivElement>(null);
34 const videoRef = useRef<HTMLVideoElement>(null);
35 const progressRef = useRef<HTMLDivElement>(null);
36 const seekingRef = useRef(false);
37
38 const [playing, setPlaying] = useState(false);
39 const [muted, setMuted] = useState(false);
40 const [currentTime, setCurrentTime] = useState(0);
41 const [duration, setDuration] = useState(0);
42 const [isFullscreen, setIsFullscreen] = useState(false);
43
44 useEffect(() => {
45 const onFullscreenChange = () => {
46 setIsFullscreen(document.fullscreenElement === containerRef.current);
47 };
48 document.addEventListener("fullscreenchange", onFullscreenChange);
49 return () =>
50 document.removeEventListener("fullscreenchange", onFullscreenChange);
51 }, []);
52
53 const togglePlay = useCallback(() => {
54 const video = videoRef.current;
55 if (!video) return;
56 if (video.paused) {
57 void video.play();
58 } else {
59 video.pause();
60 }
61 }, []);
62
63 const toggleMute = useCallback(() => {
64 const video = videoRef.current;
65 if (!video) return;
66 video.muted = !video.muted;
67 setMuted(video.muted);
68 }, []);
69
70 const toggleFullscreen = useCallback(async () => {
71 const container = containerRef.current;
72 const video = videoRef.current;
73 if (!container || !video) return;
74 try {
75 if (document.fullscreenElement) {
76 await document.exitFullscreen();
77 return;
78 }
79 if (container.requestFullscreen) {
80 await container.requestFullscreen();
81 return;
82 }
83 const webkitVideo = video as HTMLVideoElement & {
84 webkitEnterFullscreen?: () => void;
85 };
86 webkitVideo.webkitEnterFullscreen?.();
87 } catch {
88 // ignore unsupported / denied fullscreen
89 }
90 }, []);
91
92 const seekFromClientX = useCallback(
93 (clientX: number) => {
94 const bar = progressRef.current;
95 const video = videoRef.current;
96 if (!bar || !video || !duration) return;
97 const rect = bar.getBoundingClientRect();
98 const pct = Math.max(
99 0,
100 Math.min(1, (clientX - rect.left) / rect.width),
101 );
102 video.currentTime = pct * duration;
103 setCurrentTime(video.currentTime);
104 },
105 [duration],
106 );
107
108 const onProgressPointerDown = useCallback(
109 (e: ReactPointerEvent<HTMLDivElement>) => {
110 e.preventDefault();
111 e.stopPropagation();
112 seekingRef.current = true;
113 e.currentTarget.setPointerCapture(e.pointerId);
114 seekFromClientX(e.clientX);
115 },
116 [seekFromClientX],
117 );
118
119 const onProgressPointerMove = useCallback(
120 (e: ReactPointerEvent<HTMLDivElement>) => {
121 if (!seekingRef.current) return;
122 seekFromClientX(e.clientX);
123 },
124 [seekFromClientX],
125 );
126
127 const onProgressPointerUp = useCallback(
128 (e: ReactPointerEvent<HTMLDivElement>) => {
129 if (!seekingRef.current) return;
130 seekingRef.current = false;
131 try {
132 e.currentTarget.releasePointerCapture(e.pointerId);
133 } catch {
134 // already released
135 }
136 seekFromClientX(e.clientX);
137 },
138 [seekFromClientX],
139 );
140
141 const progressPct = duration ? (currentTime / duration) * 100 : 0;
142
143 return (
144 <div
145 ref={containerRef}
146 className={cn(
147 "group/shot-player relative overflow-hidden rounded-xl border border-foreground/8 bg-black/40 shadow-sm",
148 isFullscreen && "flex h-full w-full items-center justify-center rounded-none border-0 bg-black",
149 className,
150 )}
151 >
152 <video
153 ref={videoRef}
154 key={src}
155 src={src}
156 preload="metadata"
157 playsInline
158 className={cn(
159 "w-full cursor-pointer object-contain",
160 isFullscreen && "max-h-full max-w-full",
161 )}
162 onClick={togglePlay}
163 onPlay={() => setPlaying(true)}
164 onPause={() => setPlaying(false)}
165 onTimeUpdate={() => {
166 if (seekingRef.current || !videoRef.current) return;
167 setCurrentTime(videoRef.current.currentTime);
168 }}
169 onLoadedMetadata={() => {
170 const v = videoRef.current;
171 if (!v) return;
172 setDuration(v.duration || 0);
173 setMuted(v.muted);
174 }}
175 onEnded={() => setPlaying(false)}
176 />
177
178 {/* Bottom overlay controls */}
179 <div
180 className={cn(
181 "pointer-events-none absolute inset-x-0 bottom-0 z-10",
182 "bg-gradient-to-t from-black/75 via-black/35 to-transparent px-3 pb-2.5 pt-10",
183 "transition-opacity duration-200",
184 playing
185 ? "opacity-0 group-hover/shot-player:opacity-100 group-focus-within/shot-player:opacity-100"
186 : "opacity-100",
187 )}
188 >
189 <div className="pointer-events-auto flex items-center justify-between gap-2">
190 <div className="flex min-w-0 items-center gap-2">
191 <button
192 type="button"
193 onClick={(e) => {
194 e.stopPropagation();
195 togglePlay();
196 }}
197 className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-white transition-colors hover:bg-white/10"
198 aria-label={playing ? "Pause" : "Play"}
199 >
200 {playing ? (
201 <Pause className="h-3.5 w-3.5" fill="currentColor" />
202 ) : (
203 <Play className="ml-0.5 h-3.5 w-3.5" fill="currentColor" />
204 )}
205 </button>
206 <span className="truncate text-[11px] tabular-nums text-white/90">
207 {formatTime(currentTime)} / {formatTime(duration)}
208 </span>
209 </div>
210
211 <div className="flex shrink-0 items-center gap-0.5">
212 <button
213 type="button"
214 onClick={(e) => {
215 e.stopPropagation();
216 toggleMute();
217 }}
218 className="grid h-7 w-7 place-items-center rounded-md text-white transition-colors hover:bg-white/10"
219 aria-label={muted ? "Unmute" : "Mute"}
220 >
221 {muted ? (
222 <VolumeX className="h-3.5 w-3.5" />
223 ) : (
224 <Volume2 className="h-3.5 w-3.5" />
225 )}
226 </button>
227 <button
228 type="button"
229 onClick={(e) => {
230 e.stopPropagation();
231 void toggleFullscreen();
232 }}
233 className="grid h-7 w-7 place-items-center rounded-md text-white transition-colors hover:bg-white/10"
234 aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
235 >
236 {isFullscreen ? (
237 <Minimize className="h-3.5 w-3.5" />
238 ) : (
239 <Maximize className="h-3.5 w-3.5" />
240 )}
241 </button>
242 </div>
243 </div>
244
245 <div
246 ref={progressRef}
247 role="slider"
248 aria-label="Playback progress"
249 aria-valuemin={0}
250 aria-valuemax={Math.round(duration)}
251 aria-valuenow={Math.round(currentTime)}
252 tabIndex={0}
253 className="pointer-events-auto group/bar relative mt-2 h-1.5 cursor-pointer touch-none rounded-full bg-white/25"
254 onPointerDown={onProgressPointerDown}
255 onPointerMove={onProgressPointerMove}
256 onPointerUp={onProgressPointerUp}
257 onPointerCancel={onProgressPointerUp}
258 onKeyDown={(e) => {
259 const video = videoRef.current;
260 if (!video || !duration) return;
261 if (e.key === "ArrowLeft") {
262 e.preventDefault();
263 video.currentTime = Math.max(0, video.currentTime - 1);
264 setCurrentTime(video.currentTime);
265 } else if (e.key === "ArrowRight") {
266 e.preventDefault();
267 video.currentTime = Math.min(duration, video.currentTime + 1);
268 setCurrentTime(video.currentTime);
269 }
270 }}
271 >
272 <div
273 className="h-full rounded-full bg-white"
274 style={{ width: `${progressPct}%` }}
275 />
276 <div
277 className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow-sm"
278 style={{ left: `${progressPct}%` }}
279 />
280 </div>
281 </div>
282 </div>
283 );
284 }
285
285 lines Plain Text