diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 8ebce3b..329a9f6 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -1,460 +1,64 @@ -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 ( - - - - ); -} +import { VideoControls } from "./videoPlayer/VideoControls"; +import { useVideoPlayer } from "./videoPlayer/useVideoPlayer"; 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; + const player = useVideoPlayer(src); return (
event.stopPropagation()} >