'use client'; /* eslint-disable react-hooks/set-state-in-effect */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject, } from 'react'; import Hls, { type Level } from 'hls.js'; import { toast } from 'sonner'; import type { AnnotationStroke } from '@/components/annotation-canvas'; import type { BunnyPlaybackState, BunnyQualityOption, PlayerAdapter, Version, } from '@/components/video-page/types'; import { validateAnnotationStrokes } from '@/lib/validation'; import { clampSeekTime, getAdjacentPlaybackSpeed, getFrameIndexAtTime, getFrameStepLabel, getFrameStepSeconds, getPlayheadPercent, isTypingTarget, normalizeFrameRate, resolvePlayerShortcut, resolveSkipAmount as resolveSkipAmountFor, timeFromClientX as timeFromClientXWithin, } from '@/components/video-page/hooks/video-player-utils'; interface UseVideoPlayerParams { activeVersion: Version | undefined; activeVersionId: string | null; activeProviderId: string | undefined; embedUrl: string; canInitializePlayer: boolean; iframeRef: RefObject; videoRef: RefObject; bunnyViewportRef: RefObject; timelineRef: RefObject; progressRef: RefObject; playheadRef: RefObject; scrubReadoutRef: RefObject; hlsRef: RefObject; playerRef: RefObject; formatTime: (seconds: number) => string; formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string; speedOptions: number[]; scheduleWatchProgressSaveRef: RefObject< (input: { progress: number; duration?: number; immediate?: boolean; force?: boolean }) => void >; setViewingAnnotation: (strokes: AnnotationStroke[] | null) => void; } export function useVideoPlayer({ activeVersion, activeVersionId, activeProviderId, embedUrl, canInitializePlayer, iframeRef, videoRef, bunnyViewportRef, timelineRef, progressRef, playheadRef, scrubReadoutRef, hlsRef, playerRef, formatTime, formatBunnyQualityLabel, speedOptions, scheduleWatchProgressSaveRef, setViewingAnnotation, }: UseVideoPlayerParams) { const [isApiLoaded, setIsApiLoaded] = useState(false); const [isReady, setIsReady] = useState(false); const [bunnyPlaybackState, setBunnyPlaybackState] = useState('none'); const [currentTime, setCurrentTime] = useState(0); const [videoDuration, setVideoDuration] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [isMuted, setIsMuted] = useState(false); const [isFrameMode, setIsFrameMode] = useState(false); const [estimatedFrameRate, setEstimatedFrameRate] = useState(null); const [isDragging, setIsDragging] = useState(false); const isDraggingRef = useRef(false); // Scrubbing: the playhead position is driven directly via DOM (rAF) to avoid // per-frame React re-renders. These refs feed that loop. const dragTimeRef = useRef(0); const dragRectRef = useRef(null); const durationRef = useRef(0); // Live scrubbing: coalesce seeks so we never queue stale ones (keeps HLS // responsive). scrubTargetRef is the latest desired time; isSeekingRef is true // while a seek is in flight; wasPlayingBeforeScrubRef restores play on release. const scrubTargetRef = useRef(null); const isSeekingRef = useRef(false); const wasPlayingBeforeScrubRef = useRef(false); const [playbackSpeed, setPlaybackSpeed] = useState(1); const [qualityOptions, setQualityOptions] = useState([]); const [selectedQualityLevel, setSelectedQualityLevel] = useState(-1); const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto'); const pendingHlsQualityRef = useRef(null); const bunnySourceSwitchResumeRef = useRef<{ time: number; wasPlaying: boolean } | null>(null); const previousVersionKeyRef = useRef(null); const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false); const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState(0); const [cursorIdle, setCursorIdle] = useState(false); const cursorIdleTimerRef = useRef | null>(null); const bunnyRetryTimerRef = useRef | null>(null); const bunnyFrameCallbackIdRef = useRef(null); const bunnyFrameSampleRef = useRef<{ mediaTime: number; presentedFrames: number } | null>(null); const [isFullscreenMode, setIsFullscreenMode] = useState(false); const [showComments, setShowComments] = useState(true); const [isMobileCommentsOpen, setIsMobileCommentsOpen] = useState(false); // Keyboard/button seeks have no drag to key the readout off, so flash it for a // moment instead — stepping frame by frame is exactly when the count matters. const [isSeekReadoutVisible, setIsSeekReadoutVisible] = useState(false); const seekReadoutTimerRef = useRef | null>(null); const flashSeekReadout = useCallback(() => { setIsSeekReadoutVisible(true); if (seekReadoutTimerRef.current) clearTimeout(seekReadoutTimerRef.current); seekReadoutTimerRef.current = setTimeout(() => setIsSeekReadoutVisible(false), 1200); }, []); useEffect(() => { return () => { if (seekReadoutTimerRef.current) clearTimeout(seekReadoutTimerRef.current); }; }, []); const frameStepSeconds = useMemo( () => getFrameStepSeconds(estimatedFrameRate), [estimatedFrameRate] ); const frameStepLabel = useMemo(() => getFrameStepLabel(estimatedFrameRate), [estimatedFrameRate]); const stopBunnyFrameTracking = useCallback(() => { const videoEl = videoRef.current; const callbackId = bunnyFrameCallbackIdRef.current; if (videoEl && callbackId !== null && typeof videoEl.cancelVideoFrameCallback === 'function') { videoEl.cancelVideoFrameCallback(callbackId); } bunnyFrameCallbackIdRef.current = null; bunnyFrameSampleRef.current = null; }, [videoRef]); const startBunnyFrameTracking = useCallback(() => { const videoEl = videoRef.current; if (!videoEl || typeof videoEl.requestVideoFrameCallback !== 'function') return; stopBunnyFrameTracking(); const trackFrameRate = ( _now: number, metadata: { mediaTime: number; presentedFrames: number } ) => { const previousSample = bunnyFrameSampleRef.current; bunnyFrameSampleRef.current = { mediaTime: metadata.mediaTime, presentedFrames: metadata.presentedFrames, }; // Samples that straddle a seek compare frames from two different points in // the media timeline, so the ratio is meaningless — skip them. if (previousSample && !videoEl.seeking) { const deltaFrames = metadata.presentedFrames - previousSample.presentedFrames; const deltaTime = metadata.mediaTime - previousSample.mediaTime; if (deltaFrames > 0 && deltaTime > 0) { const nextFrameRate = normalizeFrameRate(deltaFrames / deltaTime); if (nextFrameRate !== null) { setEstimatedFrameRate(nextFrameRate); } } } bunnyFrameCallbackIdRef.current = videoEl.requestVideoFrameCallback(trackFrameRate); }; bunnyFrameCallbackIdRef.current = videoEl.requestVideoFrameCallback(trackFrameRate); }, [stopBunnyFrameTracking, videoRef]); useEffect(() => { isDraggingRef.current = isDragging; }, [isDragging]); useEffect(() => { const viewportEl = bunnyViewportRef.current; if (!viewportEl || typeof ResizeObserver === 'undefined') return; const updateFrameWidth = () => { const viewportWidth = viewportEl.clientWidth; const viewportHeight = viewportEl.clientHeight; if (viewportWidth <= 0 || viewportHeight <= 0) return; setBunnyPortraitFrameWidth(Math.min(viewportWidth, viewportHeight * (9 / 16))); }; updateFrameWidth(); const observer = new ResizeObserver(updateFrameWidth); observer.observe(viewportEl); return () => observer.disconnect(); }, [activeVersionId, bunnyViewportRef]); const handleVideoMouseMove = useCallback(() => { setCursorIdle(false); if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); const shouldHideControls = isFullscreenMode; if (isPlaying || shouldHideControls) { cursorIdleTimerRef.current = setTimeout(() => { setCursorIdle(true); }, 1000); } }, [isFullscreenMode, isPlaying]); const handleVideoMouseLeave = useCallback(() => { if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); setCursorIdle(false); }, []); useEffect(() => { return () => { if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); }; }, []); useEffect(() => { if (isApiLoaded) return; if (window.YT) { setIsApiLoaded(true); return; } const tag = document.createElement('script'); tag.src = 'https://www.youtube.com/iframe_api'; // A document with no