mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Show a timecode + frame readout above the timeline while dragging the playhead, and flash it for a moment on keyboard/button seeks so frame stepping is visible too. Position and text are written from the existing rAF/DOM path that drives the playhead, so the readout stays smooth without extra React renders. Two supporting fixes the count depends on: - Seed the frame rate from the HLS manifest FRAME-RATE attribute so a frame number is available before playback ever starts; previously the rate was only ever measured from requestVideoFrameCallback and stayed null until the video had played. - Snap the measured rate to the nearest broadcast standard and skip samples taken mid-seek. A drifting float slid the count by whole frames late in a long video, and re-publishing a slightly different float on every presented frame forced a re-render per video frame.
1430 lines
47 KiB
TypeScript
1430 lines
47 KiB
TypeScript
'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';
|
|
|
|
// A frame number is only meaningful against a stable rate: a raw measurement
|
|
// drifts (29.94, 30.07, ...) and would slide the count by whole frames late in a
|
|
// long video. Snap to the nearest broadcast standard when we are close enough.
|
|
const STANDARD_FRAME_RATES = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 120];
|
|
|
|
function normalizeFrameRate(rate: number | undefined): number | null {
|
|
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 12 || rate > 120) return null;
|
|
const standard = STANDARD_FRAME_RATES.find((value) => Math.abs(rate - value) / value < 0.015);
|
|
return standard ?? rate;
|
|
}
|
|
|
|
interface UseVideoPlayerParams {
|
|
activeVersion: Version | undefined;
|
|
activeVersionId: string | null;
|
|
activeProviderId: string | undefined;
|
|
embedUrl: string;
|
|
canInitializePlayer: boolean;
|
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
videoRef: RefObject<HTMLVideoElement | null>;
|
|
bunnyViewportRef: RefObject<HTMLDivElement | null>;
|
|
timelineRef: RefObject<HTMLDivElement | null>;
|
|
progressRef: RefObject<HTMLDivElement | null>;
|
|
playheadRef: RefObject<HTMLDivElement | null>;
|
|
scrubReadoutRef: RefObject<HTMLDivElement | null>;
|
|
hlsRef: RefObject<Hls | null>;
|
|
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
|
|
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<BunnyPlaybackState>('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<number | null>(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<DOMRect | null>(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<number | null>(null);
|
|
const isSeekingRef = useRef(false);
|
|
const wasPlayingBeforeScrubRef = useRef(false);
|
|
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
|
const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]);
|
|
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
|
|
const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto');
|
|
const pendingHlsQualityRef = useRef<number | null>(null);
|
|
const bunnySourceSwitchResumeRef = useRef<{ time: number; wasPlaying: boolean } | null>(null);
|
|
const previousVersionKeyRef = useRef<string | null>(null);
|
|
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
|
|
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
|
|
const [cursorIdle, setCursorIdle] = useState(false);
|
|
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const bunnyFrameCallbackIdRef = useRef<number | null>(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<ReturnType<typeof setTimeout> | 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(() => {
|
|
if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) {
|
|
return 1 / estimatedFrameRate;
|
|
}
|
|
return 1;
|
|
}, [estimatedFrameRate]);
|
|
|
|
const frameStepLabel = useMemo(() => {
|
|
if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) {
|
|
return '1f';
|
|
}
|
|
return '1s';
|
|
}, [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';
|
|
const firstScriptTag = document.getElementsByTagName('script')[0];
|
|
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
|
|
|
|
window.onYouTubeIframeAPIReady = () => {
|
|
setIsApiLoaded(true);
|
|
};
|
|
}, [isApiLoaded]);
|
|
|
|
useEffect(() => {
|
|
if (!canInitializePlayer) return;
|
|
if (!activeProviderId) return;
|
|
const isYoutube = activeProviderId === 'youtube';
|
|
const isBunny = activeProviderId === 'bunny';
|
|
const isR2 = activeProviderId === 'r2';
|
|
|
|
if (isYoutube && !isApiLoaded) return;
|
|
if (!isYoutube && !isBunny && !isR2) return;
|
|
|
|
const currentVersionKey = `${activeProviderId ?? 'none'}:${activeVersionId ?? 'none'}`;
|
|
const versionChanged = previousVersionKeyRef.current !== currentVersionKey;
|
|
previousVersionKeyRef.current = currentVersionKey;
|
|
|
|
setIsReady(false);
|
|
setBunnyPlaybackState('none');
|
|
setCurrentTime(0);
|
|
setVideoDuration(0);
|
|
setIsPlaying(false);
|
|
setIsMuted(false);
|
|
setEstimatedFrameRate(null);
|
|
setPlaybackSpeed(1);
|
|
setQualityOptions((prev) => (versionChanged ? [] : prev));
|
|
setSelectedQualityLevel(bunnySourcePreference === 'original' ? -2 : -1);
|
|
setIsBunnyPortraitSource(false);
|
|
|
|
if (playerRef.current) {
|
|
try {
|
|
playerRef.current.destroy();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
playerRef.current = null;
|
|
}
|
|
if (hlsRef.current) {
|
|
try {
|
|
hlsRef.current.destroy();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
hlsRef.current = null;
|
|
}
|
|
if (bunnyRetryTimerRef.current) {
|
|
clearTimeout(bunnyRetryTimerRef.current);
|
|
bunnyRetryTimerRef.current = null;
|
|
}
|
|
stopBunnyFrameTracking();
|
|
|
|
const initPlayer = () => {
|
|
if (isYoutube) {
|
|
if (!iframeRef.current) return;
|
|
playerRef.current = new YT.Player(iframeRef.current, {
|
|
events: {
|
|
onReady: (event: YT.PlayerEvent) => {
|
|
setIsReady(true);
|
|
const dur = event.target.getDuration();
|
|
if (dur > 0) setVideoDuration(dur);
|
|
},
|
|
onStateChange: (event: YT.OnStateChangeEvent) => {
|
|
setIsPlaying(event.data === YT.PlayerState.PLAYING);
|
|
|
|
if (event.data === YT.PlayerState.PAUSED) {
|
|
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
|
|
const playerDuration = playerRef.current?.getDuration?.() || 0;
|
|
scheduleWatchProgressSaveRef.current({
|
|
progress: playerCurrentTime,
|
|
duration: playerDuration,
|
|
immediate: true,
|
|
force: true,
|
|
});
|
|
}
|
|
|
|
if (event.data === YT.PlayerState.PLAYING) {
|
|
const dur = event.target.getDuration();
|
|
if (dur > 0) setVideoDuration(dur);
|
|
}
|
|
},
|
|
},
|
|
});
|
|
} else if (isBunny) {
|
|
const videoEl = videoRef.current;
|
|
if (!videoEl) return;
|
|
|
|
const bunnyOriginalUrl = embedUrl.includes('/playlist.m3u8')
|
|
? embedUrl.replace('/playlist.m3u8', '/original')
|
|
: '';
|
|
|
|
let cachedDuration = 0;
|
|
let destroyed = false;
|
|
let retryAttempt = 0;
|
|
let usingHlsJs = false;
|
|
let hlsInstance: Hls | null = null;
|
|
let sourceMode: 'hls' | 'original' =
|
|
bunnySourcePreference === 'original' ? 'original' : 'hls';
|
|
const clearRetryTimer = () => {
|
|
if (bunnyRetryTimerRef.current) {
|
|
clearTimeout(bunnyRetryTimerRef.current);
|
|
bunnyRetryTimerRef.current = null;
|
|
}
|
|
};
|
|
const scheduleRetry = (retryFn: () => void) => {
|
|
clearRetryTimer();
|
|
bunnyRetryTimerRef.current = setTimeout(() => {
|
|
if (!destroyed) {
|
|
retryFn();
|
|
}
|
|
}, 3000);
|
|
};
|
|
const getRetryUrl = (baseUrl: string) => {
|
|
retryAttempt += 1;
|
|
const separator = baseUrl.includes('?') ? '&' : '?';
|
|
return `${baseUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
|
|
};
|
|
const retryNativeLoad = () => {
|
|
videoEl.src = getRetryUrl(embedUrl);
|
|
videoEl.load();
|
|
};
|
|
const retryOriginalLoad = () => {
|
|
if (!bunnyOriginalUrl) return;
|
|
videoEl.src = getRetryUrl(bunnyOriginalUrl);
|
|
videoEl.load();
|
|
};
|
|
const retryHlsLoad = () => {
|
|
if (destroyed || !hlsInstance) return;
|
|
const retryUrl = getRetryUrl(embedUrl);
|
|
try {
|
|
hlsInstance.stopLoad();
|
|
} catch {
|
|
// ignore stop-load failures and continue with a fresh loadSource
|
|
}
|
|
hlsInstance.loadSource(retryUrl);
|
|
hlsInstance.startLoad(-1);
|
|
};
|
|
const activateOriginalFallback = (): boolean => {
|
|
if (!bunnyOriginalUrl) return false;
|
|
sourceMode = 'original';
|
|
usingHlsJs = false;
|
|
clearRetryTimer();
|
|
if (hlsRef.current) {
|
|
try {
|
|
hlsRef.current.destroy();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
hlsRef.current = null;
|
|
}
|
|
hlsInstance = null;
|
|
setSelectedQualityLevel(-2);
|
|
setBunnyPlaybackState('processing');
|
|
setIsReady(false);
|
|
retryOriginalLoad();
|
|
return true;
|
|
};
|
|
|
|
const syncDuration = () => {
|
|
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
|
cachedDuration = videoEl.duration;
|
|
setVideoDuration(videoEl.duration);
|
|
}
|
|
};
|
|
|
|
const saveProgress = () => {
|
|
const current = videoEl.currentTime || 0;
|
|
const duration =
|
|
Number.isFinite(videoEl.duration) && videoEl.duration > 0
|
|
? videoEl.duration
|
|
: cachedDuration;
|
|
scheduleWatchProgressSaveRef.current({
|
|
progress: current,
|
|
duration,
|
|
immediate: true,
|
|
force: true,
|
|
});
|
|
};
|
|
|
|
const onLoadedMetadata = () => {
|
|
if (destroyed) return;
|
|
clearRetryTimer();
|
|
if (sourceMode === 'original') {
|
|
setSelectedQualityLevel(-2);
|
|
}
|
|
setBunnyPlaybackState(sourceMode === 'original' ? 'processing' : 'none');
|
|
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
|
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
|
}
|
|
setIsReady(true);
|
|
const resumeState = bunnySourceSwitchResumeRef.current;
|
|
if (resumeState) {
|
|
const knownDuration =
|
|
Number.isFinite(videoEl.duration) && videoEl.duration > 0
|
|
? videoEl.duration
|
|
: cachedDuration;
|
|
const targetTime =
|
|
knownDuration > 0
|
|
? Math.min(Math.max(0, resumeState.time), Math.max(0, knownDuration - 0.01))
|
|
: Math.max(0, resumeState.time);
|
|
videoEl.currentTime = targetTime;
|
|
setCurrentTime(targetTime);
|
|
bunnySourceSwitchResumeRef.current = null;
|
|
if (resumeState.wasPlaying) {
|
|
videoEl
|
|
.play()
|
|
.catch((err) =>
|
|
console.error('Error resuming Bunny video after source switch:', err)
|
|
);
|
|
}
|
|
}
|
|
syncDuration();
|
|
if (!videoEl.paused) {
|
|
startBunnyFrameTracking();
|
|
}
|
|
};
|
|
|
|
const onPlay = () => {
|
|
setIsPlaying(true);
|
|
if (sourceMode !== 'original') {
|
|
setBunnyPlaybackState('none');
|
|
}
|
|
syncDuration();
|
|
startBunnyFrameTracking();
|
|
};
|
|
|
|
const onPause = () => {
|
|
setIsPlaying(false);
|
|
stopBunnyFrameTracking();
|
|
saveProgress();
|
|
};
|
|
|
|
const onEnded = () => {
|
|
setIsPlaying(false);
|
|
stopBunnyFrameTracking();
|
|
saveProgress();
|
|
};
|
|
|
|
const onTimeUpdate = () => {
|
|
if (!isDraggingRef.current) {
|
|
setCurrentTime(videoEl.currentTime || 0);
|
|
}
|
|
if (
|
|
Number.isFinite(videoEl.duration) &&
|
|
videoEl.duration > 0 &&
|
|
videoEl.duration !== cachedDuration
|
|
) {
|
|
cachedDuration = videoEl.duration;
|
|
setVideoDuration(videoEl.duration);
|
|
}
|
|
};
|
|
const onVideoError = () => {
|
|
if (destroyed) return;
|
|
if (usingHlsJs) return;
|
|
if (videoEl.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
|
setBunnyPlaybackState('error');
|
|
return;
|
|
}
|
|
if (sourceMode === 'hls') {
|
|
if (activateOriginalFallback()) return;
|
|
setIsReady(false);
|
|
setBunnyPlaybackState('processing');
|
|
scheduleRetry(retryNativeLoad);
|
|
return;
|
|
}
|
|
setIsReady(false);
|
|
setBunnyPlaybackState('processing');
|
|
scheduleRetry(retryOriginalLoad);
|
|
};
|
|
|
|
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
videoEl.addEventListener('play', onPlay);
|
|
videoEl.addEventListener('pause', onPause);
|
|
videoEl.addEventListener('ended', onEnded);
|
|
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
|
videoEl.addEventListener('error', onVideoError);
|
|
|
|
const configureHlsLevels = (levels: Level[]) => {
|
|
// The manifest usually declares FRAME-RATE, which gives us a frame
|
|
// count before playback ever starts; measurement refines it later.
|
|
for (const level of levels) {
|
|
const manifestFrameRate = normalizeFrameRate(level.frameRate);
|
|
if (manifestFrameRate !== null) {
|
|
setEstimatedFrameRate(manifestFrameRate);
|
|
break;
|
|
}
|
|
}
|
|
|
|
setQualityOptions(
|
|
levels.map((level, index) => ({
|
|
level: index,
|
|
label: formatBunnyQualityLabel(level, index),
|
|
}))
|
|
);
|
|
const pendingQuality = pendingHlsQualityRef.current;
|
|
pendingHlsQualityRef.current = null;
|
|
|
|
if (pendingQuality === null || pendingQuality === -1) {
|
|
if (hlsInstance) {
|
|
hlsInstance.currentLevel = -1;
|
|
hlsInstance.nextLevel = -1;
|
|
}
|
|
setSelectedQualityLevel(-1);
|
|
return;
|
|
}
|
|
|
|
if (pendingQuality >= 0 && pendingQuality < levels.length && hlsInstance) {
|
|
hlsInstance.currentLevel = pendingQuality;
|
|
hlsInstance.nextLevel = pendingQuality;
|
|
setSelectedQualityLevel(pendingQuality);
|
|
return;
|
|
}
|
|
|
|
setSelectedQualityLevel(-1);
|
|
};
|
|
|
|
if (sourceMode === 'original' && bunnyOriginalUrl) {
|
|
retryOriginalLoad();
|
|
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
|
sourceMode = 'hls';
|
|
videoEl.src = embedUrl;
|
|
videoEl.load();
|
|
} else if (Hls.isSupported()) {
|
|
sourceMode = 'hls';
|
|
usingHlsJs = true;
|
|
const hls = new Hls();
|
|
hlsInstance = hls;
|
|
hlsRef.current = hls;
|
|
hls.attachMedia(videoEl);
|
|
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
if (!destroyed) {
|
|
hls.loadSource(embedUrl);
|
|
}
|
|
});
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
|
if (destroyed) return;
|
|
clearRetryTimer();
|
|
setBunnyPlaybackState('none');
|
|
configureHlsLevels(data.levels);
|
|
setIsReady(true);
|
|
syncDuration();
|
|
});
|
|
|
|
hls.on(Hls.Events.ERROR, (_, data) => {
|
|
if (destroyed) return;
|
|
const responseCode = (data as { response?: { code?: number } }).response?.code;
|
|
const isManifestLoadFailure =
|
|
data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR ||
|
|
data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
|
|
const hasProcessingLikeStatus =
|
|
responseCode === undefined ||
|
|
responseCode === 0 ||
|
|
responseCode === 403 ||
|
|
responseCode === 404 ||
|
|
responseCode === 423 ||
|
|
responseCode === 429 ||
|
|
responseCode === 503;
|
|
const isLikelyProcessing = isManifestLoadFailure && hasProcessingLikeStatus;
|
|
const isNetworkPreMetadataProcessing =
|
|
data.type === Hls.ErrorTypes.NETWORK_ERROR &&
|
|
hasProcessingLikeStatus &&
|
|
videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
|
const isUnknownPreMetadataProcessing =
|
|
!data.details && !data.type && videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
|
if (
|
|
isLikelyProcessing ||
|
|
isNetworkPreMetadataProcessing ||
|
|
isUnknownPreMetadataProcessing
|
|
) {
|
|
if (activateOriginalFallback()) {
|
|
return;
|
|
}
|
|
setIsReady(false);
|
|
setBunnyPlaybackState('processing');
|
|
scheduleRetry(retryHlsLoad);
|
|
return;
|
|
}
|
|
|
|
if (data.fatal) {
|
|
setBunnyPlaybackState('error');
|
|
console.error('Fatal HLS error:', data);
|
|
}
|
|
});
|
|
} else {
|
|
setBunnyPlaybackState('error');
|
|
console.error('HLS is not supported in this browser.');
|
|
}
|
|
|
|
playerRef.current = {
|
|
playVideo: () => {
|
|
videoEl.play().catch((err) => console.error('Error playing Bunny video:', err));
|
|
},
|
|
pauseVideo: () => videoEl.pause(),
|
|
seekTo: (time: number) => {
|
|
videoEl.currentTime = time;
|
|
},
|
|
mute: () => {
|
|
videoEl.muted = true;
|
|
},
|
|
unMute: () => {
|
|
videoEl.muted = false;
|
|
},
|
|
isMuted: () => videoEl.muted,
|
|
getCurrentTime: () => videoEl.currentTime || 0,
|
|
getDuration: () => {
|
|
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
|
|
return cachedDuration;
|
|
},
|
|
getPlayerState: () =>
|
|
videoEl.paused
|
|
? (window.YT?.PlayerState?.PAUSED ?? 2)
|
|
: (window.YT?.PlayerState?.PLAYING ?? 1),
|
|
setPlaybackRate: (rate: number) => {
|
|
videoEl.playbackRate = rate;
|
|
},
|
|
destroy: () => {
|
|
destroyed = true;
|
|
clearRetryTimer();
|
|
stopBunnyFrameTracking();
|
|
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
videoEl.removeEventListener('play', onPlay);
|
|
videoEl.removeEventListener('pause', onPause);
|
|
videoEl.removeEventListener('ended', onEnded);
|
|
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
|
videoEl.removeEventListener('error', onVideoError);
|
|
if (hlsRef.current) {
|
|
try {
|
|
hlsRef.current.destroy();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
hlsRef.current = null;
|
|
}
|
|
videoEl.removeAttribute('src');
|
|
videoEl.load();
|
|
},
|
|
};
|
|
} else if (isR2) {
|
|
const videoEl = videoRef.current;
|
|
if (!videoEl) return;
|
|
|
|
let cachedDuration = 0;
|
|
let destroyed = false;
|
|
|
|
const syncDuration = () => {
|
|
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
|
cachedDuration = videoEl.duration;
|
|
setVideoDuration(videoEl.duration);
|
|
}
|
|
};
|
|
|
|
const saveProgress = () => {
|
|
const current = videoEl.currentTime || 0;
|
|
const duration =
|
|
Number.isFinite(videoEl.duration) && videoEl.duration > 0
|
|
? videoEl.duration
|
|
: cachedDuration;
|
|
scheduleWatchProgressSaveRef.current({
|
|
progress: current,
|
|
duration,
|
|
immediate: true,
|
|
force: true,
|
|
});
|
|
};
|
|
|
|
const onLoadedMetadata = () => {
|
|
if (destroyed) return;
|
|
setBunnyPlaybackState('none');
|
|
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
|
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
|
}
|
|
setIsReady(true);
|
|
syncDuration();
|
|
if (!videoEl.paused) {
|
|
startBunnyFrameTracking();
|
|
}
|
|
};
|
|
|
|
const onPlay = () => {
|
|
setIsPlaying(true);
|
|
setBunnyPlaybackState('none');
|
|
syncDuration();
|
|
startBunnyFrameTracking();
|
|
};
|
|
|
|
const onPause = () => {
|
|
setIsPlaying(false);
|
|
stopBunnyFrameTracking();
|
|
saveProgress();
|
|
};
|
|
|
|
const onEnded = () => {
|
|
setIsPlaying(false);
|
|
stopBunnyFrameTracking();
|
|
saveProgress();
|
|
};
|
|
|
|
const onTimeUpdate = () => {
|
|
if (!isDraggingRef.current) {
|
|
setCurrentTime(videoEl.currentTime || 0);
|
|
}
|
|
syncDuration();
|
|
};
|
|
|
|
const onVideoError = () => {
|
|
if (destroyed) return;
|
|
setBunnyPlaybackState('error');
|
|
};
|
|
|
|
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
videoEl.addEventListener('play', onPlay);
|
|
videoEl.addEventListener('pause', onPause);
|
|
videoEl.addEventListener('ended', onEnded);
|
|
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
|
videoEl.addEventListener('error', onVideoError);
|
|
|
|
const playbackSrc =
|
|
embedUrl.startsWith('/') && typeof window !== 'undefined'
|
|
? `${window.location.origin}${embedUrl}`
|
|
: embedUrl;
|
|
videoEl.src = playbackSrc;
|
|
videoEl.load();
|
|
|
|
playerRef.current = {
|
|
playVideo: () => {
|
|
videoEl.play().catch((err) => console.error('Error playing video:', err));
|
|
},
|
|
pauseVideo: () => videoEl.pause(),
|
|
seekTo: (time: number) => {
|
|
videoEl.currentTime = time;
|
|
},
|
|
mute: () => {
|
|
videoEl.muted = true;
|
|
},
|
|
unMute: () => {
|
|
videoEl.muted = false;
|
|
},
|
|
isMuted: () => videoEl.muted,
|
|
getCurrentTime: () => videoEl.currentTime || 0,
|
|
getDuration: () => {
|
|
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
|
|
return cachedDuration;
|
|
},
|
|
getPlayerState: () =>
|
|
videoEl.paused
|
|
? (window.YT?.PlayerState?.PAUSED ?? 2)
|
|
: (window.YT?.PlayerState?.PLAYING ?? 1),
|
|
setPlaybackRate: (rate: number) => {
|
|
videoEl.playbackRate = rate;
|
|
},
|
|
destroy: () => {
|
|
destroyed = true;
|
|
stopBunnyFrameTracking();
|
|
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
videoEl.removeEventListener('play', onPlay);
|
|
videoEl.removeEventListener('pause', onPause);
|
|
videoEl.removeEventListener('ended', onEnded);
|
|
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
|
videoEl.removeEventListener('error', onVideoError);
|
|
videoEl.removeAttribute('src');
|
|
videoEl.load();
|
|
},
|
|
};
|
|
}
|
|
};
|
|
|
|
const timeout = setTimeout(() => {
|
|
if (isYoutube) {
|
|
if (window.YT?.Player) {
|
|
initPlayer();
|
|
} else {
|
|
window.onYouTubeIframeAPIReady = initPlayer;
|
|
}
|
|
} else if (isBunny || isR2) {
|
|
initPlayer();
|
|
}
|
|
}, 100);
|
|
|
|
return () => {
|
|
clearTimeout(timeout);
|
|
if (isYoutube) {
|
|
window.onYouTubeIframeAPIReady = undefined;
|
|
}
|
|
if (playerRef.current) {
|
|
try {
|
|
playerRef.current.destroy();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
playerRef.current = null;
|
|
}
|
|
if (hlsRef.current) {
|
|
try {
|
|
hlsRef.current.destroy();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
hlsRef.current = null;
|
|
}
|
|
if (bunnyRetryTimerRef.current) {
|
|
clearTimeout(bunnyRetryTimerRef.current);
|
|
bunnyRetryTimerRef.current = null;
|
|
}
|
|
stopBunnyFrameTracking();
|
|
};
|
|
}, [
|
|
activeProviderId,
|
|
activeVersionId,
|
|
embedUrl,
|
|
isApiLoaded,
|
|
canInitializePlayer,
|
|
formatBunnyQualityLabel,
|
|
bunnySourcePreference,
|
|
hlsRef,
|
|
iframeRef,
|
|
playerRef,
|
|
scheduleWatchProgressSaveRef,
|
|
startBunnyFrameTracking,
|
|
stopBunnyFrameTracking,
|
|
videoRef,
|
|
]);
|
|
|
|
const toggleFullscreen = useCallback(() => {
|
|
if (!document.fullscreenElement) {
|
|
document.documentElement
|
|
.requestFullscreen()
|
|
.then(() => {
|
|
setIsFullscreenMode(true);
|
|
setShowComments(false);
|
|
})
|
|
.catch((err) => {
|
|
console.error('Fullscreen failed:', err);
|
|
toast.error('Unable to enter fullscreen mode');
|
|
});
|
|
} else {
|
|
document
|
|
.exitFullscreen()
|
|
.then(() => {
|
|
setIsFullscreenMode(false);
|
|
setShowComments(true);
|
|
})
|
|
.catch((err) => {
|
|
console.error('Exit fullscreen failed:', err);
|
|
toast.error('Unable to exit fullscreen mode');
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleFullscreenChange = () => {
|
|
const isCurrentlyFullscreen = !!document.fullscreenElement;
|
|
setIsFullscreenMode(isCurrentlyFullscreen);
|
|
if (isCurrentlyFullscreen) {
|
|
setShowComments(false);
|
|
} else {
|
|
setShowComments(true);
|
|
}
|
|
};
|
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isReady || !playerRef.current) return;
|
|
|
|
const interval = setInterval(() => {
|
|
if (!isDragging && playerRef.current) {
|
|
if (playerRef.current.getCurrentTime) {
|
|
setCurrentTime(playerRef.current.getCurrentTime());
|
|
}
|
|
}
|
|
}, 250);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [isReady, isDragging, activeVersion?.providerId, playerRef]);
|
|
|
|
const duration = useMemo(() => {
|
|
return videoDuration || activeVersion?.duration || 0;
|
|
}, [videoDuration, activeVersion?.duration]);
|
|
|
|
useEffect(() => {
|
|
durationRef.current = duration;
|
|
}, [duration]);
|
|
|
|
// Read through a ref so the rAF loop below is not torn down and rebuilt every
|
|
// time the measured frame rate is re-published.
|
|
const frameRateRef = useRef<number | null>(null);
|
|
useEffect(() => {
|
|
frameRateRef.current = estimatedFrameRate;
|
|
}, [estimatedFrameRate]);
|
|
|
|
// Position the progress fill + playhead + scrub readout directly on the DOM
|
|
// (no React state / re-render) so scrubbing and playback stay smooth at the
|
|
// display's refresh rate instead of stepping ~4x/sec.
|
|
const applyPlayhead = useCallback(
|
|
(time: number) => {
|
|
const d = durationRef.current;
|
|
const percent = d > 0 ? Math.max(0, Math.min(100, (time / d) * 100)) : 0;
|
|
if (progressRef.current) progressRef.current.style.width = `${percent}%`;
|
|
if (playheadRef.current) playheadRef.current.style.left = `calc(${percent}% - 2px)`;
|
|
|
|
const readoutEl = scrubReadoutRef.current;
|
|
if (readoutEl) {
|
|
// Clamp in CSS rather than JS so the badge stays inside the timeline at
|
|
// either end without measuring it on every frame.
|
|
readoutEl.style.left = `clamp(3rem, ${percent}%, calc(100% - 3rem))`;
|
|
const rate = frameRateRef.current;
|
|
if (rate === null) {
|
|
readoutEl.textContent = formatTime(time);
|
|
} else {
|
|
// Frame N covers [N/rate, (N+1)/rate); the epsilon keeps a time that
|
|
// lands exactly on a boundary from floating-point-ing down to N-1.
|
|
const lastFrame = d > 0 ? Math.max(0, Math.ceil(d * rate) - 1) : 0;
|
|
const frame = Math.min(Math.floor(time * rate + 1e-6), lastFrame);
|
|
readoutEl.textContent = `${formatTime(time)} · f${frame}`;
|
|
}
|
|
}
|
|
},
|
|
[progressRef, playheadRef, scrubReadoutRef, formatTime]
|
|
);
|
|
|
|
// Live-preview seek for the HTML5 video element (Bunny/R2/direct). Coalesced:
|
|
// only one seek is in flight at a time; the newest target is chased on
|
|
// 'seeked' so we stay responsive without flooding hls.js with stale seeks.
|
|
const requestScrubSeek = useCallback(
|
|
(time: number) => {
|
|
const videoEl = videoRef.current;
|
|
if (!videoEl) return; // YouTube (iframe) keeps seek-on-release only
|
|
scrubTargetRef.current = time;
|
|
if (isSeekingRef.current) return;
|
|
isSeekingRef.current = true;
|
|
try {
|
|
videoEl.currentTime = time;
|
|
} catch {
|
|
isSeekingRef.current = false;
|
|
}
|
|
},
|
|
[videoRef]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const videoEl = videoRef.current;
|
|
if (!videoEl) return;
|
|
const onSeeked = () => {
|
|
const target = scrubTargetRef.current;
|
|
if (
|
|
isDraggingRef.current &&
|
|
target !== null &&
|
|
Math.abs(videoEl.currentTime - target) > 0.04
|
|
) {
|
|
try {
|
|
videoEl.currentTime = target; // chase the latest scrub position
|
|
} catch {
|
|
isSeekingRef.current = false;
|
|
}
|
|
} else {
|
|
isSeekingRef.current = false;
|
|
}
|
|
};
|
|
videoEl.addEventListener('seeked', onSeeked);
|
|
return () => videoEl.removeEventListener('seeked', onSeeked);
|
|
}, [videoRef, isReady, activeProviderId]);
|
|
|
|
// While playing (live time) or dragging (cursor position), drive the playhead
|
|
// from a requestAnimationFrame loop for 60fps-smooth motion. During a drag we
|
|
// also request a (coalesced) seek so the frame previews live like an editor.
|
|
useEffect(() => {
|
|
if (!isPlaying && !isDragging) return;
|
|
let raf = 0;
|
|
const tick = () => {
|
|
if (isDraggingRef.current) {
|
|
applyPlayhead(dragTimeRef.current);
|
|
requestScrubSeek(dragTimeRef.current);
|
|
} else if (playerRef.current?.getCurrentTime) {
|
|
applyPlayhead(playerRef.current.getCurrentTime());
|
|
}
|
|
raf = requestAnimationFrame(tick);
|
|
};
|
|
raf = requestAnimationFrame(tick);
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [isPlaying, isDragging, applyPlayhead, requestScrubSeek, playerRef]);
|
|
|
|
// When idle (paused, not dragging), keep the playhead in sync with seeks and
|
|
// comment jumps. useLayoutEffect avoids a one-frame flash on mount/seek.
|
|
useLayoutEffect(() => {
|
|
if (isPlaying || isDragging) return;
|
|
applyPlayhead(currentTime);
|
|
}, [currentTime, isPlaying, isDragging, applyPlayhead]);
|
|
|
|
const resolveSkipAmount = useCallback(
|
|
(seconds: number) => {
|
|
if (!isFrameMode) return seconds;
|
|
const direction = seconds === 0 ? 1 : Math.sign(seconds);
|
|
return frameStepSeconds * direction;
|
|
},
|
|
[frameStepSeconds, isFrameMode]
|
|
);
|
|
|
|
const handleFrameModeToggle = useCallback(() => {
|
|
setIsFrameMode((prev) => !prev);
|
|
}, []);
|
|
|
|
const handlePlayPause = useCallback(() => {
|
|
if (!playerRef.current) return;
|
|
if (isPlaying) {
|
|
playerRef.current.pauseVideo();
|
|
} else {
|
|
playerRef.current.playVideo();
|
|
}
|
|
}, [isPlaying, playerRef]);
|
|
|
|
const handleSeekToTimestamp = useCallback(
|
|
(
|
|
timestamp: number,
|
|
annotation?: string | null,
|
|
options?: { pauseAfterSeek?: boolean; timestampEnd?: number | null }
|
|
) => {
|
|
setCurrentTime(timestamp);
|
|
if (playerRef.current?.seekTo) {
|
|
const playerState = playerRef.current.getPlayerState?.();
|
|
const ytPlayingState = window.YT?.PlayerState?.PLAYING ?? 1;
|
|
const ytBufferingState = window.YT?.PlayerState?.BUFFERING ?? 3;
|
|
const wasPlayingBeforeSeek =
|
|
typeof playerState === 'number'
|
|
? playerState === ytPlayingState || playerState === ytBufferingState
|
|
: isPlaying;
|
|
const hasRangeEnd = options?.timestampEnd !== undefined && options.timestampEnd !== null;
|
|
const shouldPauseAfterSeek = options?.pauseAfterSeek || hasRangeEnd;
|
|
|
|
playerRef.current.seekTo(timestamp, true);
|
|
if (shouldPauseAfterSeek) {
|
|
playerRef.current.pauseVideo();
|
|
} else if (wasPlayingBeforeSeek) {
|
|
playerRef.current.playVideo();
|
|
} else {
|
|
playerRef.current.pauseVideo();
|
|
}
|
|
}
|
|
if (annotation) {
|
|
try {
|
|
const parsed = JSON.parse(annotation);
|
|
const safe = validateAnnotationStrokes(parsed);
|
|
setViewingAnnotation(safe as AnnotationStroke[] | null);
|
|
} catch {
|
|
setViewingAnnotation(null);
|
|
}
|
|
} else {
|
|
setViewingAnnotation(null);
|
|
}
|
|
},
|
|
[isPlaying, playerRef, setViewingAnnotation]
|
|
);
|
|
|
|
const handleMuteToggle = useCallback(() => {
|
|
if (!playerRef.current) return;
|
|
if (isMuted) {
|
|
playerRef.current.unMute();
|
|
} else {
|
|
playerRef.current.mute();
|
|
}
|
|
setIsMuted(!isMuted);
|
|
}, [isMuted, playerRef]);
|
|
|
|
const handleSkip = useCallback(
|
|
(seconds: number) => {
|
|
const newTime = Math.max(0, Math.min(duration, currentTime + resolveSkipAmount(seconds)));
|
|
handleSeekToTimestamp(newTime);
|
|
flashSeekReadout();
|
|
},
|
|
[currentTime, duration, flashSeekReadout, handleSeekToTimestamp, resolveSkipAmount]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (document.querySelector('[data-slot="dialog-content"]')) {
|
|
return;
|
|
}
|
|
|
|
const target = e.target as HTMLElement;
|
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
|
return;
|
|
}
|
|
|
|
switch (e.code) {
|
|
case 'Space':
|
|
case 'KeyK':
|
|
e.preventDefault();
|
|
if (playerRef.current) {
|
|
if (isPlaying) {
|
|
playerRef.current.pauseVideo();
|
|
} else {
|
|
playerRef.current.playVideo();
|
|
}
|
|
}
|
|
break;
|
|
case 'ArrowLeft':
|
|
e.preventDefault();
|
|
handleSkip(-5);
|
|
break;
|
|
case 'ArrowRight':
|
|
e.preventDefault();
|
|
handleSkip(5);
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
{
|
|
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
|
if (currentIndex < speedOptions.length - 1) {
|
|
const newSpeed = speedOptions[currentIndex + 1];
|
|
setPlaybackSpeed(newSpeed);
|
|
playerRef.current?.setPlaybackRate(newSpeed);
|
|
}
|
|
}
|
|
break;
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
{
|
|
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
|
if (currentIndex > 0) {
|
|
const newSpeed = speedOptions[currentIndex - 1];
|
|
setPlaybackSpeed(newSpeed);
|
|
playerRef.current?.setPlaybackRate(newSpeed);
|
|
}
|
|
}
|
|
break;
|
|
case 'Comma':
|
|
if (e.shiftKey) {
|
|
e.preventDefault();
|
|
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
|
if (currentIndex > 0) {
|
|
const newSpeed = speedOptions[currentIndex - 1];
|
|
setPlaybackSpeed(newSpeed);
|
|
playerRef.current?.setPlaybackRate(newSpeed);
|
|
}
|
|
}
|
|
break;
|
|
case 'Period':
|
|
if (e.shiftKey) {
|
|
e.preventDefault();
|
|
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
|
if (currentIndex < speedOptions.length - 1) {
|
|
const newSpeed = speedOptions[currentIndex + 1];
|
|
setPlaybackSpeed(newSpeed);
|
|
playerRef.current?.setPlaybackRate(newSpeed);
|
|
}
|
|
}
|
|
break;
|
|
case 'KeyM':
|
|
e.preventDefault();
|
|
if (playerRef.current) {
|
|
if (isMuted) {
|
|
playerRef.current.unMute();
|
|
} else {
|
|
playerRef.current.mute();
|
|
}
|
|
setIsMuted(!isMuted);
|
|
}
|
|
break;
|
|
case 'KeyJ':
|
|
e.preventDefault();
|
|
if (playerRef.current?.seekTo) {
|
|
const newTime = Math.max(0, currentTime - 10);
|
|
playerRef.current.seekTo(newTime, true);
|
|
setCurrentTime(newTime);
|
|
flashSeekReadout();
|
|
}
|
|
break;
|
|
case 'KeyL':
|
|
e.preventDefault();
|
|
if (playerRef.current?.seekTo) {
|
|
const newTime = Math.min(duration, currentTime + 10);
|
|
playerRef.current.seekTo(newTime, true);
|
|
setCurrentTime(newTime);
|
|
flashSeekReadout();
|
|
}
|
|
break;
|
|
case 'KeyF':
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [
|
|
isPlaying,
|
|
currentTime,
|
|
duration,
|
|
isMuted,
|
|
playbackSpeed,
|
|
speedOptions,
|
|
flashSeekReadout,
|
|
handleSkip,
|
|
toggleFullscreen,
|
|
playerRef,
|
|
]);
|
|
|
|
const handleSpeedChange = useCallback(
|
|
(speed: number) => {
|
|
setPlaybackSpeed(speed);
|
|
playerRef.current?.setPlaybackRate(speed);
|
|
},
|
|
[playerRef]
|
|
);
|
|
|
|
const handleQualityChange = useCallback(
|
|
(level: number) => {
|
|
const shouldCaptureSourceSwitch =
|
|
activeProviderId === 'bunny' &&
|
|
((level === -2 && bunnySourcePreference !== 'original') ||
|
|
(level !== -2 && bunnySourcePreference === 'original'));
|
|
|
|
if (shouldCaptureSourceSwitch) {
|
|
const fallbackCurrentTime = videoRef.current?.currentTime ?? 0;
|
|
const current = playerRef.current?.getCurrentTime?.() ?? fallbackCurrentTime;
|
|
bunnySourceSwitchResumeRef.current = {
|
|
time: Number.isFinite(current) ? Math.max(0, current) : 0,
|
|
wasPlaying: isPlaying,
|
|
};
|
|
}
|
|
|
|
if (level === -2) {
|
|
pendingHlsQualityRef.current = null;
|
|
setBunnySourcePreference('original');
|
|
setSelectedQualityLevel(-2);
|
|
return;
|
|
}
|
|
|
|
pendingHlsQualityRef.current = level;
|
|
setBunnySourcePreference('auto');
|
|
|
|
const hls = hlsRef.current;
|
|
if (!hls) {
|
|
setSelectedQualityLevel(level === -1 ? -1 : level);
|
|
return;
|
|
}
|
|
|
|
if (level === -1) {
|
|
hls.currentLevel = -1;
|
|
hls.nextLevel = -1;
|
|
setSelectedQualityLevel(-1);
|
|
return;
|
|
}
|
|
|
|
hls.currentLevel = level;
|
|
hls.nextLevel = level;
|
|
setSelectedQualityLevel(level);
|
|
},
|
|
[activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef]
|
|
);
|
|
|
|
// Convert a clientX into a time using the timeline rect captured at drag start
|
|
// (avoids a layout read on every move).
|
|
const timeFromClientX = useCallback((clientX: number) => {
|
|
const rect = dragRectRef.current;
|
|
if (!rect || rect.width === 0) return 0;
|
|
const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
return percentage * durationRef.current;
|
|
}, []);
|
|
|
|
const handleTimelineMouseDown = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!timelineRef.current) return;
|
|
// Cache the rect once for the whole drag; the rAF loop reads dragTimeRef.
|
|
dragRectRef.current = timelineRef.current.getBoundingClientRect();
|
|
const newTime = timeFromClientX(e.clientX);
|
|
dragTimeRef.current = newTime;
|
|
// Freeze playback while scrubbing so the previewed frames don't fight the
|
|
// player; resume on release if it was playing.
|
|
wasPlayingBeforeScrubRef.current = isPlaying;
|
|
if (isPlaying) playerRef.current?.pauseVideo?.();
|
|
setIsDragging(true);
|
|
applyPlayhead(newTime);
|
|
setCurrentTime(newTime);
|
|
requestScrubSeek(newTime);
|
|
},
|
|
[applyPlayhead, requestScrubSeek, timeFromClientX, timelineRef, isPlaying, playerRef]
|
|
);
|
|
|
|
const handleTimelineMouseMove = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!isDraggingRef.current) return;
|
|
const newTime = timeFromClientX(e.clientX);
|
|
dragTimeRef.current = newTime;
|
|
setCurrentTime(newTime);
|
|
},
|
|
[timeFromClientX]
|
|
);
|
|
|
|
// Commit the final scrub position and restore playback if needed.
|
|
const endScrub = useCallback(() => {
|
|
if (!isDraggingRef.current) return;
|
|
setIsDragging(false);
|
|
const finalTime = dragTimeRef.current;
|
|
setCurrentTime(finalTime);
|
|
const videoEl = videoRef.current;
|
|
if (videoEl) {
|
|
try {
|
|
videoEl.currentTime = finalTime;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
} else {
|
|
playerRef.current?.seekTo?.(finalTime, true);
|
|
}
|
|
if (wasPlayingBeforeScrubRef.current) {
|
|
playerRef.current?.playVideo?.();
|
|
wasPlayingBeforeScrubRef.current = false;
|
|
}
|
|
}, [playerRef, videoRef]);
|
|
|
|
const handleTimelineMouseUp = useCallback(() => {
|
|
endScrub();
|
|
}, [endScrub]);
|
|
|
|
// While dragging, track the cursor anywhere on the page (not just over the
|
|
// timeline) so a fast or off-bar drag keeps scrubbing smoothly, and release
|
|
// anywhere to commit the seek.
|
|
useEffect(() => {
|
|
if (!isDragging) return;
|
|
const onMove = (e: MouseEvent) => {
|
|
const newTime = timeFromClientX(e.clientX);
|
|
dragTimeRef.current = newTime;
|
|
setCurrentTime(newTime);
|
|
};
|
|
window.addEventListener('mousemove', onMove);
|
|
window.addEventListener('mouseup', endScrub);
|
|
return () => {
|
|
window.removeEventListener('mousemove', onMove);
|
|
window.removeEventListener('mouseup', endScrub);
|
|
};
|
|
}, [isDragging, timeFromClientX, endScrub]);
|
|
|
|
return {
|
|
isReady,
|
|
bunnyPlaybackState,
|
|
currentTime,
|
|
setCurrentTime,
|
|
videoDuration,
|
|
setVideoDuration,
|
|
isPlaying,
|
|
isMuted,
|
|
isFrameMode,
|
|
frameStepSeconds,
|
|
frameStepLabel,
|
|
isDragging,
|
|
showScrubReadout: isDragging || isSeekReadoutVisible,
|
|
playbackSpeed,
|
|
qualityOptions,
|
|
selectedQualityLevel,
|
|
isBunnyPortraitSource,
|
|
bunnyPortraitFrameWidth,
|
|
cursorIdle,
|
|
isFullscreenMode,
|
|
showComments,
|
|
isMobileCommentsOpen,
|
|
setShowComments,
|
|
setIsMobileCommentsOpen,
|
|
handleVideoMouseMove,
|
|
handleVideoMouseLeave,
|
|
handlePlayPause,
|
|
handleSeekToTimestamp,
|
|
handleMuteToggle,
|
|
handleFrameModeToggle,
|
|
handleSkip,
|
|
handleSpeedChange,
|
|
handleQualityChange,
|
|
handleTimelineMouseDown,
|
|
handleTimelineMouseMove,
|
|
handleTimelineMouseUp,
|
|
toggleFullscreen,
|
|
};
|
|
}
|