feat(video-player): add Bunny original-source fallback and quality option, plus approval/download UI tweaks

This commit is contained in:
Yusuf İpek
2026-02-26 10:02:11 +03:00
parent 7f87d717d2
commit 15fa9452a4
7 changed files with 212 additions and 65 deletions
@@ -896,6 +896,7 @@ function BunnyPanel({
let isPlaying = false; let isPlaying = false;
let destroyed = false; let destroyed = false;
let retryAttempt = 0; let retryAttempt = 0;
let sourceMode: 'hls' | 'original' = 'hls';
let retryTimer: ReturnType<typeof setTimeout> | null = null; let retryTimer: ReturnType<typeof setTimeout> | null = null;
const clearRetryTimer = () => { const clearRetryTimer = () => {
@@ -956,6 +957,7 @@ function BunnyPanel({
videoEl.removeEventListener('pause', onPause); videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('ended', onEnded); videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata); videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
videoEl.removeEventListener('error', onError);
if (hlsRef.current) { if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ } try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null; hlsRef.current = null;
@@ -978,18 +980,45 @@ function BunnyPanel({
const onPlay = () => { isPlaying = true; }; const onPlay = () => { isPlaying = true; };
const onPause = () => { isPlaying = false; }; const onPause = () => { isPlaying = false; };
const onEnded = () => { isPlaying = false; }; const onEnded = () => { isPlaying = false; };
const hlsUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/playlist.m3u8`;
const originalUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/original`;
const activateOriginalFallback = (): void => {
sourceMode = 'original';
clearRetryTimer();
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null;
}
videoEl.src = getRetryUrl(originalUrl);
videoEl.load();
};
const onError = () => {
if (destroyed) return;
if (videoEl.readyState >= HTMLMediaElement.HAVE_METADATA) {
return;
}
if (sourceMode === 'hls') {
activateOriginalFallback();
return;
}
scheduleRetry(() => {
videoEl.src = getRetryUrl(originalUrl);
videoEl.load();
});
};
videoEl.addEventListener('loadedmetadata', onLoadedMetadata); videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
videoEl.addEventListener('timeupdate', onTimeUpdate); videoEl.addEventListener('timeupdate', onTimeUpdate);
videoEl.addEventListener('play', onPlay); videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause); videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('ended', onEnded); videoEl.addEventListener('ended', onEnded);
videoEl.addEventListener('error', onError);
const hlsUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/playlist.m3u8`;
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
sourceMode = 'hls';
videoEl.src = hlsUrl; videoEl.src = hlsUrl;
videoEl.load(); videoEl.load();
} else if (Hls.isSupported()) { } else if (Hls.isSupported()) {
sourceMode = 'hls';
const hls = new Hls(); const hls = new Hls();
hlsRef.current = hls; hlsRef.current = hls;
hls.attachMedia(videoEl); hls.attachMedia(videoEl);
@@ -1023,7 +1052,16 @@ function BunnyPanel({
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA; && videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) { if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
if (sourceMode === 'hls') {
activateOriginalFallback();
return;
}
scheduleRetry(() => { scheduleRetry(() => {
if (sourceMode === 'original') {
videoEl.src = getRetryUrl(originalUrl);
videoEl.load();
return;
}
const retryUrl = getRetryUrl(hlsUrl); const retryUrl = getRetryUrl(hlsUrl);
try { try {
hls.stopLoad(); hls.stopLoad();
+3 -1
View File
@@ -381,6 +381,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}, [videoDuration, activeVersion?.duration]); }, [videoDuration, activeVersion?.duration]);
const selectedQualityLabel = useMemo(() => { const selectedQualityLabel = useMemo(() => {
if (selectedQualityLevel === -2) return 'Original';
if (selectedQualityLevel === -1) return 'Auto'; if (selectedQualityLevel === -1) return 'Auto';
return qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto'; return qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto';
}, [qualityOptions, selectedQualityLevel]); }, [qualityOptions, selectedQualityLevel]);
@@ -514,7 +515,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
? `/projects/${propProjectId}` ? `/projects/${propProjectId}`
: (video?.projectId ? `/projects/${video.projectId}` : '/'); : (video?.projectId ? `/projects/${video.projectId}` : '/');
const isBunnyVersion = activeVersion?.providerId === 'bunny'; const isBunnyVersion = activeVersion?.providerId === 'bunny';
const showBunnyProcessingOverlay = isBunnyVersion && bunnyPlaybackState === 'processing'; const showBunnyProcessingOverlay = isBunnyVersion && bunnyPlaybackState === 'processing' && !isReady;
const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error'; const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error';
const confirmGuestName = useCallback(() => { const confirmGuestName = useCallback(() => {
@@ -916,6 +917,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
requests={approvalRequests} requests={approvalRequests}
currentUserId={currentUserId} currentUserId={currentUserId}
canRequestApproval={canRequestApproval} canRequestApproval={canRequestApproval}
onOpenApprovalRequest={handleOpenApprovalRequestDialog}
isLoadingRequests={isLoadingApprovals} isLoadingRequests={isLoadingApprovals}
isSubmittingDecision={isSubmittingApprovalDecision} isSubmittingDecision={isSubmittingApprovalDecision}
isCancelingRequest={isCancelingApprovalRequest} isCancelingRequest={isCancelingApprovalRequest}
@@ -20,6 +20,7 @@ interface ApprovalRequestsPanelProps {
requests: ApprovalRequest[]; requests: ApprovalRequest[];
currentUserId: string | null; currentUserId: string | null;
canRequestApproval: boolean; canRequestApproval: boolean;
onOpenApprovalRequest: () => void;
isLoadingRequests: boolean; isLoadingRequests: boolean;
isSubmittingDecision: boolean; isSubmittingDecision: boolean;
isCancelingRequest: boolean; isCancelingRequest: boolean;
@@ -58,6 +59,7 @@ export function ApprovalRequestsPanel({
requests, requests,
currentUserId, currentUserId,
canRequestApproval, canRequestApproval,
onOpenApprovalRequest,
isLoadingRequests, isLoadingRequests,
isSubmittingDecision, isSubmittingDecision,
isCancelingRequest, isCancelingRequest,
@@ -102,10 +104,20 @@ export function ApprovalRequestsPanel({
<div className="px-4 pb-4 space-y-3 overflow-y-auto"> <div className="px-4 pb-4 space-y-3 overflow-y-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{requests.length} request(s)</p> <p className="text-xs text-muted-foreground">{requests.length} request(s)</p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={onOpenApprovalRequest}
disabled={!canRequestApproval || !!pendingRequest}
>
Request Approval
</Button>
<Button size="sm" variant="ghost" onClick={onRefresh} disabled={isLoadingRequests}> <Button size="sm" variant="ghost" onClick={onRefresh} disabled={isLoadingRequests}>
{isLoadingRequests ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />} {isLoadingRequests ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
</Button> </Button>
</div> </div>
</div>
{error ? ( {error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"> <div className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
+42 -16
View File
@@ -55,6 +55,10 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
if (!providerVideoId) return null; if (!providerVideoId) return null;
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/playlist.m3u8`; return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/playlist.m3u8`;
}, [providerVideoId]); }, [providerVideoId]);
const originalUrl = useMemo(() => {
if (!providerVideoId) return null;
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/original`;
}, [providerVideoId]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -62,6 +66,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
let destroyed = false; let destroyed = false;
let usingHlsJs = false; let usingHlsJs = false;
let sourceMode: 'hls' | 'original' = 'hls';
const clearRetry = () => { const clearRetry = () => {
if (retryTimerRef.current) { if (retryTimerRef.current) {
@@ -77,10 +82,22 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
}, 3000); }, 3000);
}; };
const getRetryUrl = () => { const getRetryUrl = (baseUrl: string) => {
retryAttemptRef.current += 1; retryAttemptRef.current += 1;
const separator = playlistUrl.includes('?') ? '&' : '?'; const separator = baseUrl.includes('?') ? '&' : '?';
return `${playlistUrl}${separator}retry=${Date.now()}-${retryAttemptRef.current}`; return `${baseUrl}${separator}retry=${Date.now()}-${retryAttemptRef.current}`;
};
const loadOriginal = (): boolean => {
if (!originalUrl) return false;
sourceMode = 'original';
usingHlsJs = false;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
video.src = getRetryUrl(originalUrl);
video.load();
return true;
}; };
const onLoadedMetadata = () => { const onLoadedMetadata = () => {
@@ -101,12 +118,18 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
return; return;
} }
setLoadError(false); setLoadError(false);
if (sourceMode === 'hls' && loadOriginal()) {
return;
}
scheduleRetry(() => { scheduleRetry(() => {
if (usingHlsJs && hlsRef.current) { if (sourceMode === 'original' && originalUrl) {
hlsRef.current.loadSource(getRetryUrl()); video.src = getRetryUrl(originalUrl);
video.load();
} else if (usingHlsJs && hlsRef.current) {
hlsRef.current.loadSource(getRetryUrl(playlistUrl));
hlsRef.current.startLoad(-1); hlsRef.current.startLoad(-1);
} else { } else {
video.src = getRetryUrl(); video.src = getRetryUrl(playlistUrl);
video.load(); video.load();
} }
}); });
@@ -124,6 +147,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
const hls = new Hls(); const hls = new Hls();
hlsRef.current = hls; hlsRef.current = hls;
usingHlsJs = true; usingHlsJs = true;
sourceMode = 'hls';
hls.attachMedia(video); hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, () => { hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (!destroyed) hls.loadSource(playlistUrl); if (!destroyed) hls.loadSource(playlistUrl);
@@ -131,10 +155,12 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
hls.on(Hls.Events.ERROR, (_event, data) => { hls.on(Hls.Events.ERROR, (_event, data) => {
if (destroyed) return; if (destroyed) return;
if (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) { if (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) {
scheduleRetry(() => hls.loadSource(getRetryUrl())); if (loadOriginal()) return;
scheduleRetry(() => hls.loadSource(getRetryUrl(playlistUrl)));
} }
}); });
} else if (canPlayNativeHls) { } else if (canPlayNativeHls) {
sourceMode = 'hls';
video.src = playlistUrl; video.src = playlistUrl;
video.load(); video.load();
} else { } else {
@@ -164,7 +190,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
setDuration(0); setDuration(0);
setIsReady(false); setIsReady(false);
}; };
}, [playlistUrl]); }, [playlistUrl, originalUrl]);
const seekTo = (event: React.MouseEvent<HTMLDivElement>) => { const seekTo = (event: React.MouseEvent<HTMLDivElement>) => {
const video = videoRef.current; const video = videoRef.current;
@@ -177,17 +203,17 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
const togglePlayPause = useCallback(() => { const togglePlayPause = useCallback(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video || !isReady || isProcessing) return; if (!video || !isReady) return;
if (video.paused) void video.play(); if (video.paused) void video.play();
else video.pause(); else video.pause();
}, [isProcessing, isReady]); }, [isReady]);
const seekBy = useCallback((seconds: number) => { const seekBy = useCallback((seconds: number) => {
const video = videoRef.current; const video = videoRef.current;
if (!video || !isReady || isProcessing || !duration) return; if (!video || !isReady || !duration) return;
video.currentTime = Math.min(duration, Math.max(0, (video.currentTime || 0) + seconds)); video.currentTime = Math.min(duration, Math.max(0, (video.currentTime || 0) + seconds));
setCurrentTime(video.currentTime); setCurrentTime(video.currentTime);
}, [duration, isProcessing, isReady]); }, [duration, isReady]);
const toggleMute = useCallback(() => { const toggleMute = useCallback(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -208,11 +234,11 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
<div className="relative flex-1 min-h-0 flex items-center justify-center bg-black" onClick={togglePlayPause}> <div className="relative flex-1 min-h-0 flex items-center justify-center bg-black" onClick={togglePlayPause}>
<video ref={videoRef} className="w-full h-full object-contain bg-black" playsInline preload="metadata" /> <video ref={videoRef} className="w-full h-full object-contain bg-black" playsInline preload="metadata" />
{(isProcessing || (!isReady && !loadError)) && ( {(!isReady && !loadError) && (
<div className="absolute inset-0 bg-black/65 flex items-center justify-center"> <div className="absolute inset-0 bg-black/65 flex items-center justify-center">
<div className="flex items-center gap-2 text-white text-sm"> <div className="flex items-center gap-2 text-white text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Processing... {isProcessing ? 'Processing...' : 'Loading...'}
</div> </div>
</div> </div>
)} )}
@@ -230,7 +256,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-white hover:text-white" className="h-7 w-7 text-white hover:text-white"
disabled={!isReady || isProcessing} disabled={!isReady}
onClick={togglePlayPause} onClick={togglePlayPause}
> >
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />} {isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
@@ -251,7 +277,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
<div <div
className={cn( className={cn(
'relative h-6 rounded bg-white/10 select-none', 'relative h-6 rounded bg-white/10 select-none',
isReady && !isProcessing ? 'cursor-pointer' : 'cursor-not-allowed opacity-70' isReady ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
)} )}
onClick={seekTo} onClick={seekTo}
> >
+93 -32
View File
@@ -64,6 +64,9 @@ export function useVideoPlayer({
const [playbackSpeed, setPlaybackSpeed] = useState(1); const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]); const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]);
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1); const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto');
const pendingHlsQualityRef = useRef<number | null>(null);
const previousVersionKeyRef = useRef<string | null>(null);
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false); const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0); const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
const [cursorIdle, setCursorIdle] = useState(false); const [cursorIdle, setCursorIdle] = useState(false);
@@ -145,6 +148,10 @@ export function useVideoPlayer({
if (isYoutube && !isApiLoaded) return; if (isYoutube && !isApiLoaded) return;
if (!isYoutube && !isBunny) return; if (!isYoutube && !isBunny) return;
const currentVersionKey = `${activeProviderId ?? 'none'}:${activeVersionId ?? 'none'}`;
const versionChanged = previousVersionKeyRef.current !== currentVersionKey;
previousVersionKeyRef.current = currentVersionKey;
setIsReady(false); setIsReady(false);
setBunnyPlaybackState('none'); setBunnyPlaybackState('none');
setCurrentTime(0); setCurrentTime(0);
@@ -152,8 +159,8 @@ export function useVideoPlayer({
setIsPlaying(false); setIsPlaying(false);
setIsMuted(false); setIsMuted(false);
setPlaybackSpeed(1); setPlaybackSpeed(1);
setQualityOptions([]); setQualityOptions((prev) => (versionChanged ? [] : prev));
setSelectedQualityLevel(-1); setSelectedQualityLevel(bunnySourcePreference === 'original' ? -2 : -1);
setIsBunnyPortraitSource(false); setIsBunnyPortraitSource(false);
if (playerRef.current) { if (playerRef.current) {
@@ -204,11 +211,16 @@ export function useVideoPlayer({
const videoEl = videoRef.current; const videoEl = videoRef.current;
if (!videoEl) return; if (!videoEl) return;
const bunnyOriginalUrl = embedUrl.includes('/playlist.m3u8')
? embedUrl.replace('/playlist.m3u8', '/original')
: '';
let cachedDuration = 0; let cachedDuration = 0;
let destroyed = false; let destroyed = false;
let retryAttempt = 0; let retryAttempt = 0;
let usingHlsJs = false; let usingHlsJs = false;
let hlsInstance: Hls | null = null; let hlsInstance: Hls | null = null;
let sourceMode: 'hls' | 'original' = bunnySourcePreference === 'original' ? 'original' : 'hls';
const clearRetryTimer = () => { const clearRetryTimer = () => {
if (bunnyRetryTimerRef.current) { if (bunnyRetryTimerRef.current) {
clearTimeout(bunnyRetryTimerRef.current); clearTimeout(bunnyRetryTimerRef.current);
@@ -223,18 +235,23 @@ export function useVideoPlayer({
} }
}, 3000); }, 3000);
}; };
const getRetryUrl = () => { const getRetryUrl = (baseUrl: string) => {
retryAttempt += 1; retryAttempt += 1;
const separator = embedUrl.includes('?') ? '&' : '?'; const separator = baseUrl.includes('?') ? '&' : '?';
return `${embedUrl}${separator}retry=${Date.now()}-${retryAttempt}`; return `${baseUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
}; };
const retryNativeLoad = () => { const retryNativeLoad = () => {
videoEl.src = getRetryUrl(); videoEl.src = getRetryUrl(embedUrl);
videoEl.load();
};
const retryOriginalLoad = () => {
if (!bunnyOriginalUrl) return;
videoEl.src = getRetryUrl(bunnyOriginalUrl);
videoEl.load(); videoEl.load();
}; };
const retryHlsLoad = () => { const retryHlsLoad = () => {
if (destroyed || !hlsInstance) return; if (destroyed || !hlsInstance) return;
const retryUrl = getRetryUrl(); const retryUrl = getRetryUrl(embedUrl);
try { try {
hlsInstance.stopLoad(); hlsInstance.stopLoad();
} catch { } catch {
@@ -243,6 +260,21 @@ export function useVideoPlayer({
hlsInstance.loadSource(retryUrl); hlsInstance.loadSource(retryUrl);
hlsInstance.startLoad(-1); 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;
setBunnyPlaybackState('processing');
setIsReady(false);
retryOriginalLoad();
return true;
};
const syncDuration = () => { const syncDuration = () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) { if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
@@ -265,7 +297,7 @@ export function useVideoPlayer({
const onLoadedMetadata = () => { const onLoadedMetadata = () => {
if (destroyed) return; if (destroyed) return;
clearRetryTimer(); clearRetryTimer();
setBunnyPlaybackState('none'); setBunnyPlaybackState(sourceMode === 'original' ? 'processing' : 'none');
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) { if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth); setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
} }
@@ -275,7 +307,9 @@ export function useVideoPlayer({
const onPlay = () => { const onPlay = () => {
setIsPlaying(true); setIsPlaying(true);
if (sourceMode !== 'original') {
setBunnyPlaybackState('none'); setBunnyPlaybackState('none');
}
syncDuration(); syncDuration();
}; };
@@ -305,9 +339,16 @@ export function useVideoPlayer({
setBunnyPlaybackState('error'); setBunnyPlaybackState('error');
return; return;
} }
if (sourceMode === 'hls') {
if (activateOriginalFallback()) return;
setIsReady(false); setIsReady(false);
setBunnyPlaybackState('processing'); setBunnyPlaybackState('processing');
scheduleRetry(retryNativeLoad); scheduleRetry(retryNativeLoad);
return;
}
setIsReady(false);
setBunnyPlaybackState('processing');
scheduleRetry(retryOriginalLoad);
}; };
videoEl.addEventListener('loadedmetadata', onLoadedMetadata); videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
@@ -322,13 +363,36 @@ export function useVideoPlayer({
level: index, level: index,
label: formatBunnyQualityLabel(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); setSelectedQualityLevel(-1);
}; };
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { if (sourceMode === 'original' && bunnyOriginalUrl) {
retryOriginalLoad();
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
sourceMode = 'hls';
videoEl.src = embedUrl; videoEl.src = embedUrl;
videoEl.load(); videoEl.load();
} else if (Hls.isSupported()) { } else if (Hls.isSupported()) {
sourceMode = 'hls';
usingHlsJs = true; usingHlsJs = true;
const hls = new Hls(); const hls = new Hls();
hlsInstance = hls; hlsInstance = hls;
@@ -371,6 +435,9 @@ export function useVideoPlayer({
&& !data.type && !data.type
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA; && videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) { if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
if (activateOriginalFallback()) {
return;
}
setIsReady(false); setIsReady(false);
setBunnyPlaybackState('processing'); setBunnyPlaybackState('processing');
scheduleRetry(retryHlsLoad); scheduleRetry(retryHlsLoad);
@@ -465,7 +532,7 @@ export function useVideoPlayer({
bunnyRetryTimerRef.current = null; bunnyRetryTimerRef.current = null;
} }
}; };
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, canInitializePlayer, formatBunnyQualityLabel, hlsRef, iframeRef, playerRef, scheduleWatchProgressSaveRef, videoRef]); }, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, canInitializePlayer, formatBunnyQualityLabel, bunnySourcePreference, hlsRef, iframeRef, playerRef, scheduleWatchProgressSaveRef, videoRef]);
const toggleFullscreen = useCallback(() => { const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
@@ -529,24 +596,6 @@ export function useVideoPlayer({
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return; 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) { switch (e.code) {
case 'Space': case 'Space':
@@ -660,17 +709,16 @@ export function useVideoPlayer({
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, currentTime, duration, isMuted, playbackSpeed, speedOptions, toggleFullscreen, playerRef]); }, [isPlaying, currentTime, duration, isMuted, playbackSpeed, speedOptions, toggleFullscreen, playerRef]);
const handlePlayPause = useCallback(() => { const handlePlayPause = useCallback(() => {
if (activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none') return;
if (!playerRef.current) return; if (!playerRef.current) return;
if (isPlaying) { if (isPlaying) {
playerRef.current.pauseVideo(); playerRef.current.pauseVideo();
} else { } else {
playerRef.current.playVideo(); playerRef.current.playVideo();
} }
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, playerRef]); }, [isPlaying, playerRef]);
const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => { const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => {
setCurrentTime(timestamp); setCurrentTime(timestamp);
@@ -728,8 +776,21 @@ export function useVideoPlayer({
); );
const handleQualityChange = useCallback((level: number) => { const handleQualityChange = useCallback((level: number) => {
if (level === -2) {
pendingHlsQualityRef.current = null;
setBunnySourcePreference('original');
setSelectedQualityLevel(-2);
return;
}
pendingHlsQualityRef.current = level;
setBunnySourcePreference('auto');
const hls = hlsRef.current; const hls = hlsRef.current;
if (!hls) return; if (!hls) {
setSelectedQualityLevel(level === -1 ? -1 : level);
return;
}
if (level === -1) { if (level === -1) {
hls.currentLevel = -1; hls.currentLevel = -1;
+6
View File
@@ -376,6 +376,12 @@ export const PlayerCore = memo(function PlayerCore({
> >
Auto Auto
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleQualityChange(-2)}
className={cn(selectedQualityLevel === -2 && 'font-bold text-primary')}
>
Original
</DropdownMenuItem>
{qualityOptions.length > 0 && <DropdownMenuSeparator />} {qualityOptions.length > 0 && <DropdownMenuSeparator />}
{qualityOptions.map((option) => ( {qualityOptions.map((option) => (
<DropdownMenuItem <DropdownMenuItem
+11 -9
View File
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { DownloadMenuItems } from '@/components/video-page/download-controls'; import { DownloadControls } from '@/components/video-page/download-controls';
import { VersionDeleteDialog } from '@/components/video-page/version-delete-dialog'; import { VersionDeleteDialog } from '@/components/video-page/version-delete-dialog';
import { VersionActionsDialog } from '@/components/video-page/version-actions-dialog'; import { VersionActionsDialog } from '@/components/video-page/version-actions-dialog';
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types'; import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
@@ -205,6 +205,16 @@ export const VideoPageHeader = memo(function VideoPageHeader({
) : null} ) : null}
</Button> </Button>
<div className="hidden sm:block">
<DownloadControls
activeVersion={activeVersion}
videoCanDownload={videoCanDownload}
isDownloading={isDownloadingVideo}
activeDownloadTarget={activeDownloadTarget}
onDownload={onDownload}
/>
</div>
{versions.length >= 2 && ( {versions.length >= 2 && (
<Button variant="outline" size="sm" onClick={onOpenCompare} className="hidden sm:inline-flex"> <Button variant="outline" size="sm" onClick={onOpenCompare} className="hidden sm:inline-flex">
<GitCompareArrows className="h-4 w-4 mr-1" /> <GitCompareArrows className="h-4 w-4 mr-1" />
@@ -265,14 +275,6 @@ export const VideoPageHeader = memo(function VideoPageHeader({
<ShieldCheck className="h-4 w-4 mr-2" /> <ShieldCheck className="h-4 w-4 mr-2" />
Request Approval Request Approval
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DownloadMenuItems
activeVersion={activeVersion}
videoCanDownload={videoCanDownload}
isDownloading={isDownloadingVideo}
activeDownloadTarget={activeDownloadTarget}
onDownload={onDownload}
/>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>