import { useCallback, useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { useGalleryStore } from "../store"; import { Tooltip } from "./Tooltip"; const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2]; const CONTROLS_HIDE_DELAY_MS = 2500; const SEEK_STEP_SECONDS = 5; const VOLUME_STEP = 0.1; // Session-wide playback preferences shared across player instances. let persistedVolume = 1; let persistedMuted = false; function formatTime(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; const total = Math.floor(seconds); const s = total % 60; const m = Math.floor(total / 60) % 60; const h = Math.floor(total / 3600); if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; return `${m}:${s.toString().padStart(2, "0")}`; } interface BufferedRange { start: number; end: number; } function ControlButton({ onClick, label, active = false, children }: { onClick: () => void; label: string; active?: boolean; children: React.ReactNode; }) { return ( ); } export function VideoPlayer({ src }: { src: string }) { const containerRef = useRef(null); const videoRef = useRef(null); const trackRef = useRef(null); const hideTimerRef = useRef | null>(null); const scrubbingRef = useRef(false); const [playing, setPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [buffered, setBuffered] = useState([]); const [volume, setVolume] = useState(persistedVolume); const [muted, setMuted] = useState( () => useGalleryStore.getState().lightboxAutoMute || persistedMuted, ); const [playbackRate, setPlaybackRate] = useState(1); const [loop, setLoop] = useState(false); const [fullscreen, setFullscreen] = useState(false); const [controlsVisible, setControlsVisible] = useState(true); const [speedMenuOpen, setSpeedMenuOpen] = useState(false); const speedMenuOpenRef = useRef(speedMenuOpen); speedMenuOpenRef.current = speedMenuOpen; // ── Controls visibility ──────────────────────────────────────────────────── const showControls = useCallback(() => { setControlsVisible(true); if (hideTimerRef.current) clearTimeout(hideTimerRef.current); hideTimerRef.current = setTimeout(() => { const video = videoRef.current; if (video && !video.paused && !scrubbingRef.current && !speedMenuOpenRef.current) { setControlsVisible(false); } }, CONTROLS_HIDE_DELAY_MS); }, []); useEffect(() => { return () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }; }, []); // ── Video element wiring ─────────────────────────────────────────────────── useEffect(() => { const video = videoRef.current; if (!video) return; // Read playback prefs fresh for each opened video — not reactive, so a // settings change applies to the next video rather than the current one. const { lightboxAutoplay, lightboxAutoMute } = useGalleryStore.getState(); const startMuted = lightboxAutoMute || persistedMuted; video.volume = persistedVolume; video.muted = startMuted; setMuted(startMuted); // Autoplay when enabled; if the webview blocks it the catch leaves us paused // with controls visible, which is a fine fallback. if (lightboxAutoplay) video.play().catch(() => {}); }, [src]); const readBuffered = useCallback(() => { const video = videoRef.current; if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return; const ranges: BufferedRange[] = []; for (let i = 0; i < video.buffered.length; i++) { ranges.push({ start: video.buffered.start(i) / video.duration, end: video.buffered.end(i) / video.duration, }); } setBuffered(ranges); }, []); const togglePlay = useCallback(() => { const video = videoRef.current; if (!video) return; if (video.paused) { void video.play().catch(() => {}); } else { video.pause(); } showControls(); }, [showControls]); const seekBy = useCallback( (deltaSeconds: number) => { const video = videoRef.current; if (!video || !Number.isFinite(video.duration)) return; video.currentTime = Math.min(video.duration, Math.max(0, video.currentTime + deltaSeconds)); showControls(); }, [showControls], ); const applyVolume = useCallback( (nextVolume: number, nextMuted?: boolean) => { const video = videoRef.current; const clamped = Math.min(1, Math.max(0, nextVolume)); setVolume(clamped); persistedVolume = clamped; if (nextMuted !== undefined) { setMuted(nextMuted); persistedMuted = nextMuted; if (video) video.muted = nextMuted; } if (video) video.volume = clamped; showControls(); }, [showControls], ); const toggleMute = useCallback(() => { const next = !muted; setMuted(next); persistedMuted = next; const video = videoRef.current; if (video) video.muted = next; showControls(); }, [muted, showControls]); const toggleLoop = useCallback(() => { setLoop((value) => { const video = videoRef.current; if (video) video.loop = !value; return !value; }); showControls(); }, [showControls]); const toggleFullscreen = useCallback(() => { if (document.fullscreenElement) { void document.exitFullscreen().catch(() => {}); } else { void containerRef.current?.requestFullscreen().catch(() => {}); } showControls(); }, [showControls]); useEffect(() => { const onChange = () => setFullscreen(document.fullscreenElement !== null); document.addEventListener("fullscreenchange", onChange); return () => document.removeEventListener("fullscreenchange", onChange); }, []); const setSpeed = useCallback( (rate: number) => { setPlaybackRate(rate); const video = videoRef.current; if (video) video.playbackRate = rate; setSpeedMenuOpen(false); showControls(); }, [showControls], ); // ── Scrubber ─────────────────────────────────────────────────────────────── const seekToPointer = useCallback((clientX: number) => { const video = videoRef.current; const track = trackRef.current; if (!video || !track || !Number.isFinite(video.duration) || video.duration <= 0) return; const bounds = track.getBoundingClientRect(); const fraction = Math.min(1, Math.max(0, (clientX - bounds.left) / bounds.width)); video.currentTime = fraction * video.duration; setCurrentTime(video.currentTime); }, []); const handleTrackPointerDown = useCallback( (event: React.PointerEvent) => { event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); scrubbingRef.current = true; seekToPointer(event.clientX); }, [seekToPointer], ); const handleTrackPointerMove = useCallback( (event: React.PointerEvent) => { if (!scrubbingRef.current) return; seekToPointer(event.clientX); }, [seekToPointer], ); const handleTrackPointerUp = useCallback((event: React.PointerEvent) => { event.currentTarget.releasePointerCapture(event.pointerId); scrubbingRef.current = false; }, []); // ── Keyboard ─────────────────────────────────────────────────────────────── useEffect(() => { const handler = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) { return; } switch (event.key) { case " ": event.preventDefault(); togglePlay(); break; case "m": case "M": toggleMute(); break; case "f": case "F": toggleFullscreen(); break; case "l": case "L": toggleLoop(); break; case "ArrowLeft": if (event.shiftKey) { event.preventDefault(); seekBy(-SEEK_STEP_SECONDS); } break; case "ArrowRight": if (event.shiftKey) { event.preventDefault(); seekBy(SEEK_STEP_SECONDS); } break; case "ArrowUp": event.preventDefault(); // Adjusting volume always unmutes, matching the slider behavior. applyVolume(persistedVolume + VOLUME_STEP, false); break; case "ArrowDown": event.preventDefault(); applyVolume(persistedVolume - VOLUME_STEP, false); break; } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [togglePlay, toggleMute, toggleFullscreen, toggleLoop, seekBy, applyVolume]); const playedFraction = duration > 0 ? currentTime / duration : 0; const effectiveVolume = muted ? 0 : volume; return (
event.stopPropagation()} >