From 15fa9452a483b1be1089507e58ec753c12743e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Thu, 26 Feb 2026 10:02:11 +0300 Subject: [PATCH] feat(video-player): add Bunny original-source fallback and quality option, plus approval/download UI tweaks --- .../compare/compare-versions-page-client.tsx | 42 +++++- components/video-page-content.tsx | 4 +- .../video-page/approval-requests-panel.tsx | 18 ++- .../video-page/bunny-preview-player.tsx | 58 +++++--- .../video-page/hooks/use-video-player.ts | 129 +++++++++++++----- components/video-page/player-core.tsx | 6 + components/video-page/video-page-header.tsx | 20 +-- 7 files changed, 212 insertions(+), 65 deletions(-) diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx index ff5237c..f8f868c 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx @@ -896,6 +896,7 @@ function BunnyPanel({ let isPlaying = false; let destroyed = false; let retryAttempt = 0; + let sourceMode: 'hls' | 'original' = 'hls'; let retryTimer: ReturnType | null = null; const clearRetryTimer = () => { @@ -956,6 +957,7 @@ function BunnyPanel({ videoEl.removeEventListener('pause', onPause); videoEl.removeEventListener('ended', onEnded); videoEl.removeEventListener('loadedmetadata', onLoadedMetadata); + videoEl.removeEventListener('error', onError); if (hlsRef.current) { try { hlsRef.current.destroy(); } catch { /* ignore */ } hlsRef.current = null; @@ -978,18 +980,45 @@ function BunnyPanel({ const onPlay = () => { isPlaying = true; }; const onPause = () => { 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('timeupdate', onTimeUpdate); videoEl.addEventListener('play', onPlay); videoEl.addEventListener('pause', onPause); videoEl.addEventListener('ended', onEnded); - - const hlsUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/playlist.m3u8`; + videoEl.addEventListener('error', onError); if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { + sourceMode = 'hls'; videoEl.src = hlsUrl; videoEl.load(); } else if (Hls.isSupported()) { + sourceMode = 'hls'; const hls = new Hls(); hlsRef.current = hls; hls.attachMedia(videoEl); @@ -1023,7 +1052,16 @@ function BunnyPanel({ && videoEl.readyState < HTMLMediaElement.HAVE_METADATA; if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) { + if (sourceMode === 'hls') { + activateOriginalFallback(); + return; + } scheduleRetry(() => { + if (sourceMode === 'original') { + videoEl.src = getRetryUrl(originalUrl); + videoEl.load(); + return; + } const retryUrl = getRetryUrl(hlsUrl); try { hls.stopLoad(); diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index e0c11b7..d4a67c4 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -381,6 +381,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi }, [videoDuration, activeVersion?.duration]); const selectedQualityLabel = useMemo(() => { + if (selectedQualityLevel === -2) return 'Original'; if (selectedQualityLevel === -1) return 'Auto'; return qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto'; }, [qualityOptions, selectedQualityLevel]); @@ -514,7 +515,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi ? `/projects/${propProjectId}` : (video?.projectId ? `/projects/${video.projectId}` : '/'); const isBunnyVersion = activeVersion?.providerId === 'bunny'; - const showBunnyProcessingOverlay = isBunnyVersion && bunnyPlaybackState === 'processing'; + const showBunnyProcessingOverlay = isBunnyVersion && bunnyPlaybackState === 'processing' && !isReady; const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error'; const confirmGuestName = useCallback(() => { @@ -916,6 +917,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi requests={approvalRequests} currentUserId={currentUserId} canRequestApproval={canRequestApproval} + onOpenApprovalRequest={handleOpenApprovalRequestDialog} isLoadingRequests={isLoadingApprovals} isSubmittingDecision={isSubmittingApprovalDecision} isCancelingRequest={isCancelingApprovalRequest} diff --git a/components/video-page/approval-requests-panel.tsx b/components/video-page/approval-requests-panel.tsx index c7a34e4..45fca0a 100644 --- a/components/video-page/approval-requests-panel.tsx +++ b/components/video-page/approval-requests-panel.tsx @@ -20,6 +20,7 @@ interface ApprovalRequestsPanelProps { requests: ApprovalRequest[]; currentUserId: string | null; canRequestApproval: boolean; + onOpenApprovalRequest: () => void; isLoadingRequests: boolean; isSubmittingDecision: boolean; isCancelingRequest: boolean; @@ -58,6 +59,7 @@ export function ApprovalRequestsPanel({ requests, currentUserId, canRequestApproval, + onOpenApprovalRequest, isLoadingRequests, isSubmittingDecision, isCancelingRequest, @@ -102,9 +104,19 @@ export function ApprovalRequestsPanel({

{requests.length} request(s)

- +
+ + +
{error ? ( diff --git a/components/video-page/bunny-preview-player.tsx b/components/video-page/bunny-preview-player.tsx index ae89a5b..62bb856 100644 --- a/components/video-page/bunny-preview-player.tsx +++ b/components/video-page/bunny-preview-player.tsx @@ -55,6 +55,10 @@ export const BunnyPreviewPlayer = forwardRef { + if (!providerVideoId) return null; + return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/original`; + }, [providerVideoId]); useEffect(() => { const video = videoRef.current; @@ -62,6 +66,7 @@ export const BunnyPreviewPlayer = forwardRef { if (retryTimerRef.current) { @@ -77,10 +82,22 @@ export const BunnyPreviewPlayer = forwardRef { + const getRetryUrl = (baseUrl: string) => { retryAttemptRef.current += 1; - const separator = playlistUrl.includes('?') ? '&' : '?'; - return `${playlistUrl}${separator}retry=${Date.now()}-${retryAttemptRef.current}`; + const separator = baseUrl.includes('?') ? '&' : '?'; + 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 = () => { @@ -101,12 +118,18 @@ export const BunnyPreviewPlayer = forwardRef { - if (usingHlsJs && hlsRef.current) { - hlsRef.current.loadSource(getRetryUrl()); + if (sourceMode === 'original' && originalUrl) { + video.src = getRetryUrl(originalUrl); + video.load(); + } else if (usingHlsJs && hlsRef.current) { + hlsRef.current.loadSource(getRetryUrl(playlistUrl)); hlsRef.current.startLoad(-1); } else { - video.src = getRetryUrl(); + video.src = getRetryUrl(playlistUrl); video.load(); } }); @@ -124,6 +147,7 @@ export const BunnyPreviewPlayer = forwardRef { if (!destroyed) hls.loadSource(playlistUrl); @@ -131,10 +155,12 @@ export const BunnyPreviewPlayer = forwardRef { if (destroyed) return; if (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) { - scheduleRetry(() => hls.loadSource(getRetryUrl())); + if (loadOriginal()) return; + scheduleRetry(() => hls.loadSource(getRetryUrl(playlistUrl))); } }); } else if (canPlayNativeHls) { + sourceMode = 'hls'; video.src = playlistUrl; video.load(); } else { @@ -164,7 +190,7 @@ export const BunnyPreviewPlayer = forwardRef) => { const video = videoRef.current; @@ -177,17 +203,17 @@ export const BunnyPreviewPlayer = forwardRef { const video = videoRef.current; - if (!video || !isReady || isProcessing) return; + if (!video || !isReady) return; if (video.paused) void video.play(); else video.pause(); - }, [isProcessing, isReady]); + }, [isReady]); const seekBy = useCallback((seconds: number) => { 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)); setCurrentTime(video.currentTime); - }, [duration, isProcessing, isReady]); + }, [duration, isReady]); const toggleMute = useCallback(() => { const video = videoRef.current; @@ -208,11 +234,11 @@ export const BunnyPreviewPlayer = forwardRef