mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
refactor(video-page): split video page into modular components, hooks, and shared types
This commit is contained in:
@@ -0,0 +1,814 @@
|
||||
'use client';
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useCallback, useEffect, 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';
|
||||
|
||||
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>;
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
|
||||
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,
|
||||
hlsRef,
|
||||
playerRef,
|
||||
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 [isDragging, setIsDragging] = useState(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||
const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]);
|
||||
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
|
||||
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 [isFullscreenMode, setIsFullscreenMode] = useState(false);
|
||||
const [showComments, setShowComments] = useState(true);
|
||||
const [isMobileCommentsOpen, setIsMobileCommentsOpen] = useState(false);
|
||||
|
||||
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';
|
||||
|
||||
if (isYoutube && !isApiLoaded) return;
|
||||
if (!isYoutube && !isBunny) return;
|
||||
|
||||
setIsReady(false);
|
||||
setBunnyPlaybackState('none');
|
||||
setCurrentTime(0);
|
||||
setVideoDuration(0);
|
||||
setIsPlaying(false);
|
||||
setIsMuted(false);
|
||||
setPlaybackSpeed(1);
|
||||
setQualityOptions([]);
|
||||
setSelectedQualityLevel(-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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
let cachedDuration = 0;
|
||||
let destroyed = false;
|
||||
let retryAttempt = 0;
|
||||
let usingHlsJs = false;
|
||||
let hlsInstance: Hls | null = null;
|
||||
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 = () => {
|
||||
retryAttempt += 1;
|
||||
const separator = embedUrl.includes('?') ? '&' : '?';
|
||||
return `${embedUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
|
||||
};
|
||||
const retryNativeLoad = () => {
|
||||
videoEl.src = getRetryUrl();
|
||||
videoEl.load();
|
||||
};
|
||||
const retryHlsLoad = () => {
|
||||
if (destroyed || !hlsInstance) return;
|
||||
const retryUrl = getRetryUrl();
|
||||
try {
|
||||
hlsInstance.stopLoad();
|
||||
} catch {
|
||||
// ignore stop-load failures and continue with a fresh loadSource
|
||||
}
|
||||
hlsInstance.loadSource(retryUrl);
|
||||
hlsInstance.startLoad(-1);
|
||||
};
|
||||
|
||||
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();
|
||||
setBunnyPlaybackState('none');
|
||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
||||
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||
}
|
||||
setIsReady(true);
|
||||
syncDuration();
|
||||
};
|
||||
|
||||
const onPlay = () => {
|
||||
setIsPlaying(true);
|
||||
setBunnyPlaybackState('none');
|
||||
syncDuration();
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
setIsPlaying(false);
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
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;
|
||||
}
|
||||
setIsReady(false);
|
||||
setBunnyPlaybackState('processing');
|
||||
scheduleRetry(retryNativeLoad);
|
||||
};
|
||||
|
||||
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[]) => {
|
||||
setQualityOptions(levels.map((level, index) => ({
|
||||
level: index,
|
||||
label: formatBunnyQualityLabel(level, index),
|
||||
})));
|
||||
setSelectedQualityLevel(-1);
|
||||
};
|
||||
|
||||
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoEl.src = embedUrl;
|
||||
videoEl.load();
|
||||
} else if (Hls.isSupported()) {
|
||||
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) {
|
||||
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();
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (isYoutube) {
|
||||
if (window.YT?.Player) {
|
||||
initPlayer();
|
||||
} else {
|
||||
window.onYouTubeIframeAPIReady = initPlayer;
|
||||
}
|
||||
} else if (isBunny) {
|
||||
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;
|
||||
}
|
||||
};
|
||||
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, canInitializePlayer, formatBunnyQualityLabel, hlsRef, iframeRef, playerRef, scheduleWatchProgressSaveRef, 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(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
const isBunnyBlocked = activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none';
|
||||
const isPlaybackControlKey = [
|
||||
'Space',
|
||||
'KeyK',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'Comma',
|
||||
'Period',
|
||||
'KeyM',
|
||||
'KeyJ',
|
||||
'KeyL',
|
||||
].includes(e.code);
|
||||
if (isBunnyBlocked && isPlaybackControlKey) {
|
||||
e.preventDefault();
|
||||
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();
|
||||
if (playerRef.current) {
|
||||
const newTime = Math.max(0, currentTime - 5);
|
||||
if (playerRef.current.seekTo) {
|
||||
playerRef.current.seekTo(newTime, true);
|
||||
}
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
if (playerRef.current) {
|
||||
const newTime = Math.min(duration, currentTime + 5);
|
||||
if (playerRef.current.seekTo) {
|
||||
playerRef.current.seekTo(newTime, true);
|
||||
}
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
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);
|
||||
}
|
||||
break;
|
||||
case 'KeyL':
|
||||
e.preventDefault();
|
||||
if (playerRef.current?.seekTo) {
|
||||
const newTime = Math.min(duration, currentTime + 10);
|
||||
playerRef.current.seekTo(newTime, true);
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
break;
|
||||
case 'KeyF':
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, currentTime, duration, isMuted, playbackSpeed, speedOptions, toggleFullscreen, playerRef]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none') return;
|
||||
if (!playerRef.current) return;
|
||||
if (isPlaying) {
|
||||
playerRef.current.pauseVideo();
|
||||
} else {
|
||||
playerRef.current.playVideo();
|
||||
}
|
||||
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, playerRef]);
|
||||
|
||||
const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | 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;
|
||||
|
||||
playerRef.current.seekTo(timestamp, true);
|
||||
if (wasPlayingBeforeSeek) {
|
||||
playerRef.current.playVideo();
|
||||
} else {
|
||||
playerRef.current.pauseVideo();
|
||||
}
|
||||
}
|
||||
if (annotation) {
|
||||
try {
|
||||
const strokes = JSON.parse(annotation) as AnnotationStroke[];
|
||||
setViewingAnnotation(strokes);
|
||||
} 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 + seconds));
|
||||
handleSeekToTimestamp(newTime);
|
||||
},
|
||||
[currentTime, duration, handleSeekToTimestamp]
|
||||
);
|
||||
|
||||
const handleSpeedChange = useCallback(
|
||||
(speed: number) => {
|
||||
setPlaybackSpeed(speed);
|
||||
playerRef.current?.setPlaybackRate(speed);
|
||||
},
|
||||
[playerRef]
|
||||
);
|
||||
|
||||
const handleQualityChange = useCallback((level: number) => {
|
||||
const hls = hlsRef.current;
|
||||
if (!hls) return;
|
||||
|
||||
if (level === -1) {
|
||||
hls.currentLevel = -1;
|
||||
hls.nextLevel = -1;
|
||||
setSelectedQualityLevel(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
hls.currentLevel = level;
|
||||
hls.nextLevel = level;
|
||||
setSelectedQualityLevel(level);
|
||||
}, [hlsRef]);
|
||||
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!timelineRef.current) return;
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||
const newTime = percentage * duration;
|
||||
handleSeekToTimestamp(newTime);
|
||||
},
|
||||
[duration, handleSeekToTimestamp, timelineRef]
|
||||
);
|
||||
|
||||
const handleTimelineMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsDragging(true);
|
||||
handleTimelineClick(e);
|
||||
},
|
||||
[handleTimelineClick]
|
||||
);
|
||||
|
||||
const handleTimelineMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || !timelineRef.current) return;
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||
setCurrentTime(percentage * duration);
|
||||
},
|
||||
[isDragging, duration, timelineRef]
|
||||
);
|
||||
|
||||
const handleTimelineMouseUp = useCallback(() => {
|
||||
if (isDragging) {
|
||||
handleSeekToTimestamp(currentTime);
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, [isDragging, currentTime, handleSeekToTimestamp]);
|
||||
|
||||
return {
|
||||
isReady,
|
||||
bunnyPlaybackState,
|
||||
currentTime,
|
||||
setCurrentTime,
|
||||
videoDuration,
|
||||
setVideoDuration,
|
||||
isPlaying,
|
||||
isMuted,
|
||||
isDragging,
|
||||
playbackSpeed,
|
||||
qualityOptions,
|
||||
selectedQualityLevel,
|
||||
isBunnyPortraitSource,
|
||||
bunnyPortraitFrameWidth,
|
||||
cursorIdle,
|
||||
isFullscreenMode,
|
||||
showComments,
|
||||
isMobileCommentsOpen,
|
||||
setShowComments,
|
||||
setIsMobileCommentsOpen,
|
||||
handleVideoMouseMove,
|
||||
handleVideoMouseLeave,
|
||||
handlePlayPause,
|
||||
handleSeekToTimestamp,
|
||||
handleMuteToggle,
|
||||
handleSkip,
|
||||
handleSpeedChange,
|
||||
handleQualityChange,
|
||||
handleTimelineMouseDown,
|
||||
handleTimelineMouseMove,
|
||||
handleTimelineMouseUp,
|
||||
toggleFullscreen,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user