1/* oxlint-disable react/immutability -- Media elements expose playback controls as mutable DOM properties. */
2import {
3 MaximizeIcon,
4 MinimizeIcon,
5 MoreHorizontalIcon,
6 PauseIcon,
7 PictureInPicture2Icon,
8 PlayIcon,
9 SlidersHorizontalIcon,
10 Volume2Icon,
11 VolumeXIcon,
12} from "lucide-react";
13import { useEffect, useState } from "react";
14
15import { Button } from "../ui/button";
16import { useMediaActionMenu, type MediaActionSource } from "./MediaActions";
17
18function mediaTime(seconds: number): string {
19 const time = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0;
20 const minutes = Math.floor(time / 60);
21 const hours = minutes >= 60 ? `${Math.floor(minutes / 60)}:` : "";
22 const minuteText = String(minutes % 60).padStart(hours ? 2 : 1, "0");
23 return `${hours}${minuteText}:${String(time % 60).padStart(2, "0")}`;
24}
25
26function readVideoState(video: HTMLVideoElement) {
27 return {
28 paused: video.paused || video.ended,
29 time: video.currentTime,
30 duration: Number.isFinite(video.duration) ? video.duration : 0,
31 volume: video.muted ? 0 : video.volume,
32 rate: video.playbackRate,
33 fullscreen: document.fullscreenElement?.contains(video) ?? false,
34 pip: document.pictureInPictureElement === video,
35 };
36}
37
38function VideoActionsButton({ source }: { readonly source: MediaActionSource }) {
39 const { showMenu } = useMediaActionMenu(source);
40 return (
41 <Button
42 size="icon-sm"
43 variant="ghost"
44 aria-label="Video actions"
45 aria-haspopup="menu"
46 title="Video actions"
47 onClick={(event) => {
48 const bounds = event.currentTarget.getBoundingClientRect();
49 void showMenu({ x: bounds.right, y: bounds.bottom });
50 }}
51 >
52 <MoreHorizontalIcon aria-hidden />
53 </Button>
54 );
55}
56
57export function MediaVideoControls({
58 video,
59 actionsSource,
60}: {
61 readonly video: HTMLVideoElement;
62 readonly actionsSource: MediaActionSource | undefined;
63}) {
64 const [state, setState] = useState(() => readVideoState(video));
65 const [error, setError] = useState<string | null>(null);
66 const [settingsOpen, setSettingsOpen] = useState(false);
67
68 useEffect(() => {
69 const update = () => setState(readVideoState(video));
70 const events = [
71 "play",
72 "pause",
73 "ended",
74 "timeupdate",
75 "loadedmetadata",
76 "durationchange",
77 "volumechange",
78 "ratechange",
79 "enterpictureinpicture",
80 "leavepictureinpicture",
81 ];
82 update();
83 for (const event of events) video.addEventListener(event, update);
84 document.addEventListener("fullscreenchange", update);
85 return () => {
86 for (const event of events) video.removeEventListener(event, update);
87 document.removeEventListener("fullscreenchange", update);
88 };
89 }, [video]);
90
91 const play = async () => {
92 setError(null);
93 try {
94 if (video.paused || video.ended) await video.play();
95 else video.pause();
96 } catch {
97 setError("Video could not play. Try again.");
98 }
99 };
100 const fullscreen = async () => {
101 setError(null);
102 try {
103 if (document.fullscreenElement?.contains(video)) await document.exitFullscreen();
104 else await video.closest("[data-media-player]")?.requestFullscreen();
105 } catch {
106 setError("Fullscreen is unavailable. Try opening the video separately.");
107 }
108 };
109 const pictureInPicture = async () => {
110 setError(null);
111 try {
112 if (document.pictureInPictureElement === video) await document.exitPictureInPicture();
113 else await video.requestPictureInPicture();
114 } catch {
115 setError("Picture in picture is unavailable. Keep playing here or switch chats to float it.");
116 }
117 };
118
119 return (
120 <span
121 data-media-video-controls
122 onKeyDown={(event) => {
123 if (event.key !== "Escape" || !document.fullscreenElement?.contains(video)) return;
124 event.preventDefault();
125 event.stopPropagation();
126 void fullscreen();
127 }}
128 >
129 <input
130 type="range"
131 min={0}
132 max={state.duration || 1}
133 step={0.1}
134 value={Math.min(state.time, state.duration)}
135 disabled={state.duration === 0}
136 aria-label="Seek video"
137 aria-valuetext={`${mediaTime(state.time)} of ${mediaTime(state.duration)}`}
138 onChange={(event) => {
139 video.currentTime = Number(event.currentTarget.value);
140 }}
141 data-media-video-seek
142 />
143 <span className="flex min-w-0 flex-wrap items-center gap-0.5">
144 <Button
145 size="icon-sm"
146 variant="ghost"
147 aria-label={state.paused ? "Play video" : "Pause video"}
148 title={state.paused ? "Play" : "Pause"}
149 onClick={() => void play()}
150 >
151 {state.paused ? <PlayIcon aria-hidden /> : <PauseIcon aria-hidden />}
152 </Button>
153 <span className="min-w-0 truncate px-1 text-xs tabular-nums" aria-hidden="true">
154 {mediaTime(state.time)} / {mediaTime(state.duration)}
155 </span>
156 <span className="flex-1" />
157 <Button
158 size="icon-sm"
159 variant="ghost"
160 aria-label={state.volume === 0 ? "Unmute video" : "Mute video"}
161 title={state.volume === 0 ? "Unmute" : "Mute"}
162 onClick={() => {
163 if (video.muted || video.volume === 0) {
164 video.muted = false;
165 if (video.volume === 0) video.volume = 1;
166 } else video.muted = true;
167 }}
168 >
169 {state.volume === 0 ? <VolumeXIcon aria-hidden /> : <Volume2Icon aria-hidden />}
170 </Button>
171 <Button
172 size="icon-sm"
173 variant="ghost"
174 aria-label="Video settings"
175 title="Video settings"
176 aria-expanded={settingsOpen}
177 onClick={() => setSettingsOpen((open) => !open)}
178 >
179 <SlidersHorizontalIcon aria-hidden />
180 </Button>
181 {document.pictureInPictureEnabled && typeof video.requestPictureInPicture === "function" ? (
182 <Button
183 size="icon-sm"
184 variant="ghost"
185 aria-label={state.pip ? "Exit picture in picture" : "Picture in picture"}
186 title="Picture in picture"
187 aria-pressed={state.pip}
188 onClick={() => void pictureInPicture()}
189 >
190 <PictureInPicture2Icon aria-hidden />
191 </Button>
192 ) : null}
193 {document.fullscreenEnabled ? (
194 <Button
195 size="icon-sm"
196 variant="ghost"
197 aria-label={state.fullscreen ? "Exit fullscreen" : "Fullscreen video"}
198 title={state.fullscreen ? "Exit fullscreen" : "Fullscreen"}
199 onClick={() => void fullscreen()}
200 >
201 {state.fullscreen ? <MinimizeIcon aria-hidden /> : <MaximizeIcon aria-hidden />}
202 </Button>
203 ) : null}
204 {actionsSource ? <VideoActionsButton source={actionsSource} /> : null}
205 </span>
206 {settingsOpen ? (
207 <span
208 role="group"
209 aria-label="Video settings"
210 className="flex flex-wrap items-center gap-2 px-1 py-1 text-xs"
211 >
212 <label className="flex min-w-0 flex-1 items-center gap-2 whitespace-nowrap">
213 Volume
214 <input
215 type="range"
216 min={0}
217 max={1}
218 step={0.05}
219 value={state.volume}
220 aria-label="Video volume"
221 aria-valuetext={`${Math.round(state.volume * 100)}%`}
222 data-media-video-volume
223 onChange={(event) => {
224 video.muted = false;
225 video.volume = Number(event.currentTarget.value);
226 }}
227 />
228 </label>
229 <label className="flex items-center gap-2">
230 Speed
231 <select
232 aria-label="Playback speed"
233 data-media-video-speed
234 value={state.rate}
235 onChange={(event) => {
236 video.playbackRate = Number(event.currentTarget.value);
237 }}
238 >
239 {[0.5, 0.75, 1, 1.25, 1.5, 2].map((rate) => (
240 <option key={rate} value={rate}>
241 {rate}×
242 </option>
243 ))}
244 </select>
245 </label>
246 </span>
247 ) : null}
248 {error ? (
249 <span role="alert" className="px-1 text-xs text-destructive-foreground">
250 {error}
251 </span>
252 ) : null}
253 </span>
254 );
255}