| 1 | /* oxlint-disable react/immutability -- Media elements expose playback controls as mutable DOM properties. */ |
| 2 | import { |
| 3 | MaximizeIcon, |
| 4 | MinimizeIcon, |
| 5 | MoreHorizontalIcon, |
| 6 | PauseIcon, |
| 7 | PictureInPicture2Icon, |
| 8 | PlayIcon, |
| 9 | SlidersHorizontalIcon, |
| 10 | Volume2Icon, |
| 11 | VolumeXIcon, |
| 12 | } from "lucide-react"; |
| 13 | import { useEffect, useState } from "react"; |
| 14 | |
| 15 | import { Button } from "../ui/button"; |
| 16 | import { useMediaActionMenu, type MediaActionSource } from "./MediaActions"; |
| 17 | |
| 18 | function 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 | |
| 26 | function 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 | |
| 38 | function 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 | |
| 57 | export 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 | } |