import { ClosedCaptioningIcon, CornersOutIcon, PauseIcon, PlayIcon, SpeakerHighIcon, SpeakerXIcon, } from "@phosphor-icons/react"; import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; import { useHlsSource, useScrubManifest } from "~/hooks/use-video"; import { scrubFrameAt, videoUrl, type ScrubManifest } from "~/lib/video"; import { cn } from "~/utils/cn"; type Caption = { src: string; srcLang: string; label: string }; type Props = { id: string; version: string; title: string; captions?: Caption[]; className?: string }; const SEEK_STEP_SECONDS = 5; const THUMB_WIDTH = 160; function clock(seconds: number): string { if (!Number.isFinite(seconds)) return "0:00"; const whole = Math.floor(seconds); return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`; } function spriteStyle(manifest: ScrubManifest, id: string, version: string, time: number): CSSProperties { const frame = scrubFrameAt(manifest, time); const column = frame.x / manifest.frameWidth; const row = frame.y / manifest.frameHeight; const percent = (index: number, count: number) => (count > 1 ? (index / (count - 1)) * 100 : 0); return { backgroundImage: `url(${videoUrl(id, version, frame.sheet)})`, backgroundSize: `${manifest.columns * 100}% ${manifest.rows * 100}%`, backgroundPosition: `${percent(column, manifest.columns)}% ${percent(row, manifest.rows)}%`, }; } /** HLS player served from R2 via /videos; dragging the timeline shows sprite frames instantly and seeks on release. */ export function VideoPlayer({ id, version, title, captions = [], className }: Props) { const [video, setVideo] = useState(null); const frameRef = useRef(null); const trackRef = useRef(null); const error = useHlsSource(video, videoUrl(id, version, "master.m3u8")); const scrub = useScrubManifest(id, version); const [paused, setPaused] = useState(true); const [muted, setMuted] = useState(false); const [captionsOn, setCaptionsOn] = useState(captions.length > 0); const [cueText, setCueText] = useState(""); const [time, setTime] = useState(0); const [duration, setDuration] = useState(0); const [buffered, setBuffered] = useState(0); const [hover, setHover] = useState<{ time: number; x: number } | null>(null); const [dragging, setDraggingState] = useState(false); const draggingRef = useRef(false); const setDragging = (value: boolean) => { draggingRef.current = value; setDraggingState(value); }; useEffect(() => { const track = video?.textTracks[0]; if (!track) return; // Cues render in our own overlay so they sit above the control bar; the native renderer can't be moved. track.mode = "hidden"; const update = () => setCueText( Array.from(track.activeCues ?? []) .map((cue) => (cue instanceof VTTCue ? cue.text : "")) .join("\n"), ); track.addEventListener("cuechange", update); return () => track.removeEventListener("cuechange", update); }, [video]); useEffect(() => { if (!video) return; const sync = () => { setPaused(video.paused); setMuted(video.muted); setDuration(video.duration || 0); if (!video.seeking && !draggingRef.current) setTime(video.currentTime); setBuffered(video.buffered.length ? video.buffered.end(video.buffered.length - 1) : 0); }; const events = ["play", "pause", "timeupdate", "durationchange", "volumechange", "progress", "seeked"]; for (const name of events) video.addEventListener(name, sync); return () => { for (const name of events) video.removeEventListener(name, sync); }; }, [video]); const timeAt = (clientX: number) => { const rect = trackRef.current?.getBoundingClientRect(); if (!rect || !duration) return { time: 0, x: 0 }; const x = Math.min(rect.width, Math.max(0, clientX - rect.left)); return { time: (x / rect.width) * duration, x }; }; const seek = (target: number) => { if (!video) return; const clamped = Math.min(duration, Math.max(0, target)); setTime(clamped); video.currentTime = clamped; }; const onPointerDown = (event: PointerEvent) => { event.currentTarget.setPointerCapture(event.pointerId); const at = timeAt(event.clientX); setDragging(true); setHover(at); setTime(at.time); }; const onPointerMove = (event: PointerEvent) => { const at = timeAt(event.clientX); setHover(at); if (dragging) setTime(at.time); }; const onPointerUp = (event: PointerEvent) => { if (!dragging) return; setDragging(false); seek(timeAt(event.clientX).time); if (event.pointerType !== "mouse") setHover(null); }; const onKeyDown = (event: KeyboardEvent) => { const moves: Record = { ArrowLeft: time - SEEK_STEP_SECONDS, ArrowRight: time + SEEK_STEP_SECONDS, Home: 0, End: duration, }; if (!(event.key in moves)) return; event.preventDefault(); seek(moves[event.key]); }; const toggle = () => (video?.paused ? void video.play() : video?.pause()); const fullscreen = () => document.fullscreenElement ? void document.exitFullscreen() : void frameRef.current?.requestFullscreen(); const progress = duration ? (time / duration) * 100 : 0; const trackWidth = trackRef.current?.clientWidth ?? 0; const thumbLeft = hover ? Math.min(Math.max(0, hover.x - THUMB_WIDTH / 2), Math.max(0, trackWidth - THUMB_WIDTH)) : 0; return (
{dragging && scrub ? (
) : null} {captionsOn && cueText ? (

{cueText}

) : null} {error ?

{error}

: null}
setDragging(false)} onPointerLeave={() => !dragging && setHover(null)} onKeyDown={onKeyDown} >
{hover && !dragging ? (
{scrub ? (
) : null}
{clock(hover.time)}
) : null}
{clock(time)} / {clock(duration)} {captions.length > 0 ? ( ) : null}
); }