From 3522c3da30738b7ad6925398c4bc4190a483e829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Thu, 26 Feb 2026 11:48:21 +0300 Subject: [PATCH] feat(video-page): enhance Bunny asset handling and playback features - Added support for tracking Bunny asset readiness and processing states. - Implemented thumbnail loading error handling and retry logic for Bunny assets. - Introduced a "Ready to play" indicator for Bunny assets. - Enhanced Bunny preview player with playback speed and quality selection options. - Improved state management for video playback, including resuming playback after source switches. - Updated package.json to streamline database setup commands. --- .env.example | 3 +- components/video-card.tsx | 3 +- components/video-page/asset-list-section.tsx | 188 +++--- components/video-page/assets-pane.tsx | 70 ++- .../video-page/bunny-preview-player.tsx | 552 ++++++++++++++---- .../video-page/hooks/use-video-player.ts | 37 +- package.json | 2 +- 7 files changed, 646 insertions(+), 209 deletions(-) diff --git a/.env.example b/.env.example index 20515d7..8d5f47e 100644 --- a/.env.example +++ b/.env.example @@ -74,7 +74,8 @@ BUNNY_STREAM_LIBRARY_ID="your-library-id" # Bunny Core API key (account-level) used to enforce KeepOriginalFiles/ExposeOriginals on the library BUNNY_API_KEY="your-account-api-key" # Bunny Stream CDN base URL (for HLS streaming) -BUNNY_CDN_URL="https://vz-965f4f4a-fc1.b-cdn.net" +BUNNY_CDN_URL="your-url-to-bunny-cdn" +NEXT_PUBLIC_BUNNY_CDN_URL="your-url-to-bunny-cdn" # Bunny orphan cleanup configuration (script + external cron; app runtime does not schedule this) # Grace period is fixed at 24 hours in the script. # */15 * * * * cd /home/yusuf/Programming/OpenFrame && bun run bunny:cleanup-orphans diff --git a/components/video-card.tsx b/components/video-card.tsx index e1903a5..e45e13b 100644 --- a/components/video-card.tsx +++ b/components/video-card.tsx @@ -195,7 +195,8 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP {imgError ? (
- Processing... + Processing thumbnail... + Video may already be playable
) : ( // eslint-disable-next-line @next/next/no-img-element diff --git a/components/video-page/asset-list-section.tsx b/components/video-page/asset-list-section.tsx index ece234c..fb01bf9 100644 --- a/components/video-page/asset-list-section.tsx +++ b/components/video-page/asset-list-section.tsx @@ -1,7 +1,7 @@ 'use client'; import { memo, type ReactNode } from 'react'; -import { Download, ExternalLink, Image as ImageIcon, Loader2, Trash2 } from 'lucide-react'; +import { Download, Image as ImageIcon, Loader2, Play, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { @@ -18,6 +18,7 @@ interface AssetListSectionProps { isLoadingAssets: boolean; focusedAssetId: string | null; bunnyProcessingByAssetId: Record; + bunnyReadyByAssetId: Record; activeDownloadAssetId: string | null; activeDeleteAssetId: string | null; canDownloadAssets: boolean; @@ -35,6 +36,7 @@ export const AssetListSection = memo(function AssetListSection({ isLoadingAssets, focusedAssetId, bunnyProcessingByAssetId, + bunnyReadyByAssetId, activeDownloadAssetId, activeDeleteAssetId, canDownloadAssets, @@ -65,104 +67,108 @@ export const AssetListSection = memo(function AssetListSection({ return (
- {assets.map((asset) => ( -
- -
-
-

{asset.displayName}

-
- {asset.provider === 'BUNNY' && bunnyProcessingByAssetId[asset.id] ? ( - - - Processing - - ) : null} + {assets.map((asset) => { + const isBunnyProcessing = asset.provider === 'BUNNY' + && !!bunnyProcessingByAssetId[asset.id] + && !bunnyReadyByAssetId[asset.id]; + return ( +
+ +
+
+

{asset.displayName}

+
+ {isBunnyProcessing ? ( + + + Processing + + ) : null} +
-
-

- {asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} • {new Date(asset.createdAt).toLocaleDateString()} -

-
- - - {canDownloadAssets && asset.provider !== 'YOUTUBE' && ( - asset.provider === 'BUNNY' ? ( - - - - - - onDownloadAsset(asset, 'original')}> - - Original - - onDownloadAsset(asset, 'compressed')}> - - Compressed - - - - ) : ( - - ) - )} - - {asset.canDelete && ( +

+ {asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} • {new Date(asset.createdAt).toLocaleDateString()} +

+
- )} + + {canDownloadAssets && asset.provider !== 'YOUTUBE' && ( + asset.provider === 'BUNNY' ? ( + + + + + + onDownloadAsset(asset, 'original')}> + + Original + + onDownloadAsset(asset, 'compressed')}> + + Compressed + + + + ) : ( + + ) + )} + + {asset.canDelete && ( + + )} +
-
- ))} + ); + })} {hasMoreAssets ? ( + + + {SPEED_OPTIONS.map((speed) => ( + handleSpeedChange(speed)}> + {speed}x {speed === playbackSpeed ? '(Current)' : ''} + + ))} + + + + + + + + + handleQualityChange(-1)}> + Auto {selectedQualityLevel === -1 ? '(Current)' : ''} + + handleQualityChange(-2)}> + Original {selectedQualityLevel === -2 ? '(Current)' : ''} + + {qualityOptions.map((option) => ( + handleQualityChange(option.level)}> + {option.label} {option.level === selectedQualityLevel ? '(Current)' : ''} + + ))} + + +
(-1); const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto'); const pendingHlsQualityRef = useRef(null); + const bunnySourceSwitchResumeRef = useRef<{ time: number; wasPlaying: boolean } | null>(null); const previousVersionKeyRef = useRef(null); const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false); const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState(0); @@ -270,6 +271,7 @@ export function useVideoPlayer({ hlsRef.current = null; } hlsInstance = null; + setSelectedQualityLevel(-2); setBunnyPlaybackState('processing'); setIsReady(false); retryOriginalLoad(); @@ -297,11 +299,29 @@ export function useVideoPlayer({ 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(); }; @@ -776,6 +796,21 @@ export function useVideoPlayer({ ); 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'); @@ -802,7 +837,7 @@ export function useVideoPlayer({ hls.currentLevel = level; hls.nextLevel = level; setSelectedQualityLevel(level); - }, [hlsRef]); + }, [activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef]); const handleTimelineClick = useCallback( (e: React.MouseEvent) => { diff --git a/package.json b/package.json index 2760287..4b228d2 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "db:push": "prisma db push", "db:migrate": "prisma migrate deploy", "db:seed": "prisma db seed", - "db:setup": "bun run db:generate && bun run db:push && bun run db:migrate", + "db:setup": "bun run db:generate && bun run db:migrate", "r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run", "r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts", "bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",