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.
This commit is contained in:
Yusuf İpek
2026-02-26 11:48:21 +03:00
parent bb43a07234
commit 3522c3da30
7 changed files with 646 additions and 209 deletions
+2 -1
View File
@@ -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 Core API key (account-level) used to enforce KeepOriginalFiles/ExposeOriginals on the library
BUNNY_API_KEY="your-account-api-key" BUNNY_API_KEY="your-account-api-key"
# Bunny Stream CDN base URL (for HLS streaming) # 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) # Bunny orphan cleanup configuration (script + external cron; app runtime does not schedule this)
# Grace period is fixed at 24 hours in the script. # Grace period is fixed at 24 hours in the script.
# */15 * * * * cd /home/yusuf/Programming/OpenFrame && bun run bunny:cleanup-orphans # */15 * * * * cd /home/yusuf/Programming/OpenFrame && bun run bunny:cleanup-orphans
+2 -1
View File
@@ -195,7 +195,8 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
{imgError ? ( {imgError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/80"> <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/80">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground mb-2" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground mb-2" />
<span className="text-xs text-muted-foreground font-medium">Processing...</span> <span className="text-xs text-muted-foreground font-medium">Processing thumbnail...</span>
<span className="text-[11px] text-muted-foreground/90">Video may already be playable</span>
</div> </div>
) : ( ) : (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
+97 -91
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { memo, type ReactNode } from 'react'; 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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@@ -18,6 +18,7 @@ interface AssetListSectionProps {
isLoadingAssets: boolean; isLoadingAssets: boolean;
focusedAssetId: string | null; focusedAssetId: string | null;
bunnyProcessingByAssetId: Record<string, boolean>; bunnyProcessingByAssetId: Record<string, boolean>;
bunnyReadyByAssetId: Record<string, boolean>;
activeDownloadAssetId: string | null; activeDownloadAssetId: string | null;
activeDeleteAssetId: string | null; activeDeleteAssetId: string | null;
canDownloadAssets: boolean; canDownloadAssets: boolean;
@@ -35,6 +36,7 @@ export const AssetListSection = memo(function AssetListSection({
isLoadingAssets, isLoadingAssets,
focusedAssetId, focusedAssetId,
bunnyProcessingByAssetId, bunnyProcessingByAssetId,
bunnyReadyByAssetId,
activeDownloadAssetId, activeDownloadAssetId,
activeDeleteAssetId, activeDeleteAssetId,
canDownloadAssets, canDownloadAssets,
@@ -65,104 +67,108 @@ export const AssetListSection = memo(function AssetListSection({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{assets.map((asset) => ( {assets.map((asset) => {
<div const isBunnyProcessing = asset.provider === 'BUNNY'
key={asset.id} && !!bunnyProcessingByAssetId[asset.id]
id={`asset-card-${asset.id}`} && !bunnyReadyByAssetId[asset.id];
className={cn( return (
'rounded-lg border p-2 flex gap-3 transition-colors', <div
focusedAssetId === asset.id && 'ring-2 ring-primary border-primary/60 bg-primary/5' key={asset.id}
)} id={`asset-card-${asset.id}`}
> className={cn(
<button className="shrink-0" onClick={() => onViewAsset(asset)}> 'rounded-lg border p-2 flex gap-3 transition-colors',
{renderAssetPreview(asset)} focusedAssetId === asset.id && 'ring-2 ring-primary border-primary/60 bg-primary/5'
</button> )}
<div className="min-w-0 flex-1 space-y-1"> >
<div className="flex items-start justify-between gap-2"> <button className="shrink-0" onClick={() => onViewAsset(asset)}>
<p className="text-sm font-medium truncate">{asset.displayName}</p> {renderAssetPreview(asset)}
<div className="flex items-center gap-1 shrink-0"> </button>
{asset.provider === 'BUNNY' && bunnyProcessingByAssetId[asset.id] ? ( <div className="min-w-0 flex-1 space-y-1">
<Badge variant="secondary" className="text-[10px] gap-1"> <div className="flex items-start justify-between gap-2">
<Loader2 className="h-2.5 w-2.5 animate-spin" /> <p className="text-sm font-medium truncate">{asset.displayName}</p>
Processing <div className="flex items-center gap-1 shrink-0">
</Badge> {isBunnyProcessing ? (
) : null} <Badge variant="secondary" className="text-[10px] gap-1">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
Processing
</Badge>
) : null}
</div>
</div> </div>
</div> <p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground"> {asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} {new Date(asset.createdAt).toLocaleDateString()}
{asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} {new Date(asset.createdAt).toLocaleDateString()} </p>
</p> <div className="pt-1 flex items-center gap-1">
<div className="pt-1 flex items-center gap-1">
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="View asset"
aria-label="View asset"
disabled={asset.provider === 'BUNNY' && !!bunnyProcessingByAssetId[asset.id]}
onClick={() => onViewAsset(asset)}
>
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : <ExternalLink className="h-3 w-3" />}
</Button>
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
asset.provider === 'BUNNY' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || !!bunnyProcessingByAssetId[asset.id]}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'original')}>
<Download className="h-3 w-3 mr-2" />
Original
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'compressed')}>
<Download className="h-3 w-3 mr-2" />
Compressed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || !!bunnyProcessingByAssetId[asset.id]}
onClick={() => onDownloadAsset(asset)}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
)
)}
{asset.canDelete && (
<Button <Button
size="icon" size="icon"
variant="destructive" variant="outline"
className="h-7 w-7" className="h-7 w-7"
title="Delete asset" title={asset.kind === 'VIDEO' ? 'Play video' : 'View image'}
aria-label="Delete asset" aria-label={asset.kind === 'VIDEO' ? 'Play video' : 'View image'}
disabled={activeDeleteAssetId === asset.id} onClick={() => onViewAsset(asset)}
onClick={() => onDeleteAsset(asset.id)}
> >
{activeDeleteAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />} {asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : <Play className="h-3 w-3" />}
</Button> </Button>
)}
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
asset.provider === 'BUNNY' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'original')}>
<Download className="h-3 w-3 mr-2" />
Original
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'compressed')}>
<Download className="h-3 w-3 mr-2" />
Compressed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
onClick={() => onDownloadAsset(asset)}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
)
)}
{asset.canDelete && (
<Button
size="icon"
variant="destructive"
className="h-7 w-7"
title="Delete asset"
aria-label="Delete asset"
disabled={activeDeleteAssetId === asset.id}
onClick={() => onDeleteAsset(asset.id)}
>
{activeDeleteAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</Button>
)}
</div>
</div> </div>
</div> </div>
</div> );
))} })}
{hasMoreAssets ? ( {hasMoreAssets ? (
<Button <Button
+56 -14
View File
@@ -3,7 +3,7 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useEffect, useMemo, useRef, useState } from 'react';
import * as tus from 'tus-js-client'; import * as tus from 'tus-js-client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Download, FileVideo, Image as ImageIcon, Loader2, UploadCloud, X, Youtube } from 'lucide-react'; import { Download, FileVideo, Image as ImageIcon, Loader2, Play, UploadCloud, X, Youtube } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -76,7 +76,9 @@ export const AssetsPane = memo(function AssetsPane({
const [isUploadingBunny, setIsUploadingBunny] = useState(false); const [isUploadingBunny, setIsUploadingBunny] = useState(false);
const [bunnyProgress, setBunnyProgress] = useState(0); const [bunnyProgress, setBunnyProgress] = useState(0);
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>({}); const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>({});
const [bunnyReadyByAssetId, setBunnyReadyByAssetId] = useState<Record<string, boolean>>({});
const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState<Record<string, number>>({}); const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState<Record<string, number>>({});
const [bunnyThumbnailLoadErrorByAssetId, setBunnyThumbnailLoadErrorByAssetId] = useState<Record<string, boolean>>({});
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null); const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null); const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
@@ -217,6 +219,12 @@ export const AssetsPane = memo(function AssetsPane({
}; };
}, [selectedAsset]); }, [selectedAsset]);
useEffect(() => {
if (!selectedAsset || selectedAsset.provider !== 'BUNNY') return;
if (bunnyReadyByAssetId[selectedAsset.id]) return;
setBunnyProcessingByAssetId((prev) => (prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true }));
}, [bunnyReadyByAssetId, selectedAsset]);
const handleImageUpload = async (file: File) => { const handleImageUpload = async (file: File) => {
if (!file) return; if (!file) return;
@@ -373,6 +381,8 @@ export const AssetsPane = memo(function AssetsPane({
if (!createdAsset) { if (!createdAsset) {
throw new Error('Failed to finalize Bunny asset'); throw new Error('Failed to finalize Bunny asset');
} }
setBunnyReadyByAssetId((prev) => ({ ...prev, [createdAsset.id]: false }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [createdAsset.id]: true }));
if (bunnyInputRef.current) bunnyInputRef.current.value = ''; if (bunnyInputRef.current) bunnyInputRef.current.value = '';
setBunnyTitle(''); setBunnyTitle('');
} catch (error) { } catch (error) {
@@ -392,13 +402,25 @@ export const AssetsPane = memo(function AssetsPane({
}; };
const handleBunnyThumbnailError = (assetId: string) => { const handleBunnyThumbnailError = (assetId: string) => {
setBunnyProcessingByAssetId((prev) => ({ ...prev, [assetId]: true })); const alreadyReady = !!bunnyReadyByAssetId[assetId];
setBunnyThumbnailLoadErrorByAssetId((prev) => ({ ...prev, [assetId]: true }));
if (!alreadyReady) {
setBunnyProcessingByAssetId((prev) => (prev[assetId] ? prev : { ...prev, [assetId]: true }));
setBunnyReadyByAssetId((prev) => ({ ...prev, [assetId]: false }));
}
window.setTimeout(() => { window.setTimeout(() => {
setBunnyThumbnailRetryKeyByAssetId((prev) => ({ ...prev, [assetId]: Date.now() })); setBunnyThumbnailRetryKeyByAssetId((prev) => ({ ...prev, [assetId]: Date.now() }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [assetId]: false })); setBunnyThumbnailLoadErrorByAssetId((prev) => ({ ...prev, [assetId]: false }));
}, 10000); }, 10000);
}; };
const handleBunnyThumbnailLoad = (assetId: string) => {
setBunnyThumbnailLoadErrorByAssetId((prev) => {
if (!prev[assetId]) return prev;
return { ...prev, [assetId]: false };
});
};
const renderAssetPreview = (asset: VideoAsset) => { const renderAssetPreview = (asset: VideoAsset) => {
if (asset.kind === 'IMAGE') { if (asset.kind === 'IMAGE') {
const imageSrc = asset.thumbnailUrl || asset.sourceUrl; const imageSrc = asset.thumbnailUrl || asset.sourceUrl;
@@ -429,25 +451,36 @@ export const AssetsPane = memo(function AssetsPane({
const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0; const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0;
const isProcessing = !!bunnyProcessingByAssetId[asset.id]; const isProcessing = !!bunnyProcessingByAssetId[asset.id];
const isReadyToPlay = !!bunnyReadyByAssetId[asset.id];
const hasThumbnailLoadError = !!bunnyThumbnailLoadErrorByAssetId[asset.id];
const thumbnailSrc = asset.thumbnailUrl ? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}` : null; const thumbnailSrc = asset.thumbnailUrl ? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}` : null;
const showThumbnailImage = !!thumbnailSrc && !hasThumbnailLoadError;
return ( return (
<div className="h-24 w-36 rounded border overflow-hidden bg-muted relative flex items-center justify-center"> <div className="h-24 w-36 rounded border overflow-hidden bg-muted relative flex items-center justify-center">
{thumbnailSrc ? ( {showThumbnailImage ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={thumbnailSrc} src={thumbnailSrc}
alt={asset.displayName} alt=""
className="h-full w-full object-cover" className="h-full w-full object-cover"
onLoad={() => handleBunnyThumbnailLoad(asset.id)}
onError={() => handleBunnyThumbnailError(asset.id)} onError={() => handleBunnyThumbnailError(asset.id)}
/> />
) : isReadyToPlay ? (
<div className="h-full w-full bg-black/60 flex flex-col items-center justify-center gap-1">
<Play className="h-4 w-4 text-emerald-300" />
<span className="text-[10px] text-emerald-100 font-medium">Ready to play</span>
</div>
) : ( ) : (
<FileVideo className="h-6 w-6 text-muted-foreground" /> <FileVideo className="h-6 w-6 text-muted-foreground" />
)} )}
{isProcessing && ( {isProcessing && !isReadyToPlay && (
<div className="absolute inset-0 bg-black/65 flex flex-col items-center justify-center gap-1"> <div className="absolute inset-0 bg-black/65 flex flex-col items-center justify-center gap-1">
<Loader2 className="h-4 w-4 animate-spin text-white" /> <Loader2 className="h-4 w-4 animate-spin text-white" />
<span className="text-[10px] text-white/90 font-medium">Processing...</span> <span className="text-[10px] text-white/90 font-medium">
Processing...
</span>
</div> </div>
)} )}
</div> </div>
@@ -455,11 +488,6 @@ export const AssetsPane = memo(function AssetsPane({
}; };
const handleOpenAsset = (asset: VideoAsset) => { const handleOpenAsset = (asset: VideoAsset) => {
const isBunnyProcessing = asset.provider === 'BUNNY' && !!bunnyProcessingByAssetId[asset.id];
if (isBunnyProcessing) {
toast.info('This Bunny asset is still processing.');
return;
}
if (asset.kind === 'IMAGE') { if (asset.kind === 'IMAGE') {
if (!asset.sourceUrl) { if (!asset.sourceUrl) {
toast.error('Preview is unavailable for this asset'); toast.error('Preview is unavailable for this asset');
@@ -469,9 +497,17 @@ export const AssetsPane = memo(function AssetsPane({
setPreviewImageTitle(asset.displayName); setPreviewImageTitle(asset.displayName);
return; return;
} }
if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) {
setBunnyProcessingByAssetId((prev) => (prev[asset.id] ? prev : { ...prev, [asset.id]: true }));
}
setSelectedAsset(asset); setSelectedAsset(asset);
}; };
const selectedBunnyAssetId = selectedAsset?.provider === 'BUNNY' ? selectedAsset.id : null;
const isSelectedBunnyProcessing = selectedBunnyAssetId
? !!bunnyProcessingByAssetId[selectedBunnyAssetId] && !bunnyReadyByAssetId[selectedBunnyAssetId]
: false;
return ( return (
<div className="space-y-4" onPaste={handleImagePaste}> <div className="space-y-4" onPaste={handleImagePaste}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -608,6 +644,7 @@ export const AssetsPane = memo(function AssetsPane({
isLoadingAssets={isLoadingAssets} isLoadingAssets={isLoadingAssets}
focusedAssetId={focusedAssetId} focusedAssetId={focusedAssetId}
bunnyProcessingByAssetId={bunnyProcessingByAssetId} bunnyProcessingByAssetId={bunnyProcessingByAssetId}
bunnyReadyByAssetId={bunnyReadyByAssetId}
activeDownloadAssetId={activeDownloadAssetId} activeDownloadAssetId={activeDownloadAssetId}
activeDeleteAssetId={activeDeleteAssetId} activeDeleteAssetId={activeDeleteAssetId}
canDownloadAssets={canDownloadAssets} canDownloadAssets={canDownloadAssets}
@@ -678,7 +715,7 @@ export const AssetsPane = memo(function AssetsPane({
aria-label="Download Bunny video" aria-label="Download Bunny video"
disabled={ disabled={
activeDownloadAssetId === selectedAsset.id activeDownloadAssetId === selectedAsset.id
|| !!bunnyProcessingByAssetId[selectedAsset.id] || isSelectedBunnyProcessing
} }
> >
{activeDownloadAssetId === selectedAsset.id ? ( {activeDownloadAssetId === selectedAsset.id ? (
@@ -729,7 +766,12 @@ export const AssetsPane = memo(function AssetsPane({
<BunnyPreviewPlayer <BunnyPreviewPlayer
ref={bunnyPreviewPlayerRef} ref={bunnyPreviewPlayerRef}
providerVideoId={selectedAsset.providerVideoId} providerVideoId={selectedAsset.providerVideoId}
isProcessing={!!bunnyProcessingByAssetId[selectedAsset.id]} isProcessing={isSelectedBunnyProcessing}
onReadyToPlay={() => {
if (!selectedBunnyAssetId) return;
setBunnyReadyByAssetId((prev) => ({ ...prev, [selectedBunnyAssetId]: true }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [selectedBunnyAssetId]: false }));
}}
/> />
) )
) : null} ) : null}
+452 -100
View File
@@ -1,14 +1,23 @@
'use client'; 'use client';
/* eslint-disable react-hooks/set-state-in-effect */
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import Hls from 'hls.js'; import Hls, { type Level } from 'hls.js';
import { Loader2, Pause, Play, Volume2, VolumeX } from 'lucide-react'; import { ChevronDown, Loader2, Pause, Play, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { BunnyPlaybackState, BunnyQualityOption } from '@/components/video-page/types';
interface BunnyPreviewPlayerProps { interface BunnyPreviewPlayerProps {
providerVideoId: string | null; providerVideoId: string | null;
isProcessing: boolean; isProcessing: boolean;
onReadyToPlay?: () => void;
} }
export interface BunnyPreviewPlayerHandle { export interface BunnyPreviewPlayerHandle {
@@ -17,16 +26,16 @@ export interface BunnyPreviewPlayerHandle {
toggleMute: () => void; toggleMute: () => void;
} }
const DEFAULT_BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net'; const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
function resolveBunnyCdnHostname(): string { function resolveBunnyCdnHostname(): string | null {
const configured = process.env.NEXT_PUBLIC_BUNNY_CDN_URL; const configured = process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
if (!configured) return DEFAULT_BUNNY_PULL_ZONE_HOSTNAME; if (!configured) return null;
try { try {
const parsed = new URL(configured); const parsed = new URL(configured);
return parsed.hostname || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME; return parsed.hostname || null;
} catch { } catch {
return configured.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME; return configured.replace(/^https?:\/\//, '').replace(/\/+$/, '') || null;
} }
} }
@@ -38,37 +47,105 @@ function formatTime(value: number): string {
return `${minutes}:${seconds.toString().padStart(2, '0')}`; return `${minutes}:${seconds.toString().padStart(2, '0')}`;
} }
export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPreviewPlayerProps>(function BunnyPreviewPlayer({ providerVideoId, isProcessing }, ref) { function formatBunnyQualityLabel(level: { height?: number; bitrate?: number }, index: number): string {
if (typeof level.height === 'number' && level.height > 0) {
return `${level.height}p`;
}
if (typeof level.bitrate === 'number' && level.bitrate > 0) {
return `${Math.round(level.bitrate / 1000)} kbps`;
}
return `Level ${index + 1}`;
}
export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPreviewPlayerProps>(function BunnyPreviewPlayer({ providerVideoId, isProcessing, onReadyToPlay }, ref) {
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const hlsRef = useRef<Hls | null>(null); const hlsRef = useRef<Hls | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryAttemptRef = useRef(0); const pendingHlsQualityRef = useRef<number | null>(null);
const onReadyToPlayRef = useRef(onReadyToPlay);
const hasNotifiedReadyRef = useRef(false);
const playbackSpeedRef = useRef(1);
const sourceSwitchResumeRef = useRef<{ time: number; wasPlaying: boolean } | null>(null);
const previousProviderVideoIdRef = useRef<string | null>(null);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [loadError, setLoadError] = useState(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 [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
const bunnyCdnHostname = useMemo(() => resolveBunnyCdnHostname(), []);
const playlistUrl = useMemo(() => { const playlistUrl = useMemo(() => {
if (!providerVideoId) return null; if (!providerVideoId || !bunnyCdnHostname) return null;
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/playlist.m3u8`; return `https://${bunnyCdnHostname}/${providerVideoId}/playlist.m3u8`;
}, [providerVideoId]); }, [bunnyCdnHostname, providerVideoId]);
const originalUrl = useMemo(() => { const originalUrl = useMemo(() => {
if (!providerVideoId) return null; if (!providerVideoId || !bunnyCdnHostname) return null;
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/original`; return `https://${bunnyCdnHostname}/${providerVideoId}/original`;
}, [providerVideoId]); }, [bunnyCdnHostname, providerVideoId]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; onReadyToPlayRef.current = onReadyToPlay;
if (!video || !playlistUrl) return; }, [onReadyToPlay]);
useEffect(() => {
hasNotifiedReadyRef.current = false;
}, [providerVideoId]);
const notifyReadyToPlay = useCallback(() => {
if (hasNotifiedReadyRef.current) return;
hasNotifiedReadyRef.current = true;
onReadyToPlayRef.current?.();
}, []);
useEffect(() => {
playbackSpeedRef.current = playbackSpeed;
if (videoRef.current) {
videoRef.current.playbackRate = playbackSpeed;
}
}, [playbackSpeed]);
useEffect(() => {
const videoEl = videoRef.current;
const sourceKey = providerVideoId ?? null;
const sourceChanged = previousProviderVideoIdRef.current !== sourceKey;
previousProviderVideoIdRef.current = sourceKey;
if (!videoEl || !playlistUrl) {
setIsReady(false);
setIsPlaying(false);
setIsMuted(false);
setCurrentTime(0);
setDuration(0);
setQualityOptions((prev) => (sourceChanged ? [] : prev));
setSelectedQualityLevel(-1);
setBunnyPlaybackState('error');
return;
}
let cachedDuration = 0;
let destroyed = false; let destroyed = false;
let retryAttempt = 0;
let usingHlsJs = false; let usingHlsJs = false;
let sourceMode: 'hls' | 'original' = 'hls'; let hlsInstance: Hls | null = null;
let sourceMode: 'hls' | 'original' = bunnySourcePreference === 'original' ? 'original' : 'hls';
let attemptedAutoplay = false;
const clearRetry = () => { setIsReady(false);
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setIsMuted(videoEl.muted);
setSelectedQualityLevel(bunnySourcePreference === 'original' ? -2 : -1);
setQualityOptions((prev) => (sourceChanged ? [] : prev));
setBunnyPlaybackState('none');
const clearRetryTimer = () => {
if (retryTimerRef.current) { if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current); clearTimeout(retryTimerRef.current);
retryTimerRef.current = null; retryTimerRef.current = null;
@@ -76,121 +153,286 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
}; };
const scheduleRetry = (retryFn: () => void) => { const scheduleRetry = (retryFn: () => void) => {
clearRetry(); clearRetryTimer();
retryTimerRef.current = setTimeout(() => { retryTimerRef.current = setTimeout(() => {
if (!destroyed) retryFn(); if (!destroyed) {
retryFn();
}
}, 3000); }, 3000);
}; };
const getRetryUrl = (baseUrl: string) => { const getRetryUrl = (baseUrl: string) => {
retryAttemptRef.current += 1; retryAttempt += 1;
const separator = baseUrl.includes('?') ? '&' : '?'; const separator = baseUrl.includes('?') ? '&' : '?';
return `${baseUrl}${separator}retry=${Date.now()}-${retryAttemptRef.current}`; return `${baseUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
}; };
const loadOriginal = (): boolean => {
const retryNativeLoad = () => {
videoEl.src = getRetryUrl(playlistUrl);
videoEl.load();
};
const retryOriginalLoad = () => {
if (!originalUrl) return;
videoEl.src = getRetryUrl(originalUrl);
videoEl.load();
};
const retryHlsLoad = () => {
if (destroyed || !hlsInstance) return;
const retryUrl = getRetryUrl(playlistUrl);
try {
hlsInstance.stopLoad();
} catch {
// ignore stop-load failures and continue with a fresh loadSource
}
hlsInstance.loadSource(retryUrl);
hlsInstance.startLoad(-1);
};
const activateOriginalFallback = (): boolean => {
if (!originalUrl) return false; if (!originalUrl) return false;
sourceMode = 'original'; sourceMode = 'original';
usingHlsJs = false; usingHlsJs = false;
clearRetryTimer();
if (hlsRef.current) { if (hlsRef.current) {
hlsRef.current.destroy(); try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null; hlsRef.current = null;
} }
video.src = getRetryUrl(originalUrl); hlsInstance = null;
video.load(); setSelectedQualityLevel(-2);
setBunnyPlaybackState('processing');
setIsReady(false);
retryOriginalLoad();
return true; return true;
}; };
const syncDuration = () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
cachedDuration = videoEl.duration;
setDuration(videoEl.duration);
}
};
const attemptAutoplay = () => {
if (attemptedAutoplay) return;
attemptedAutoplay = true;
videoEl.play()
.then(() => {
notifyReadyToPlay();
})
.catch(() => {
// Autoplay can fail due to browser policy. User can still start playback manually.
});
};
const onLoadedMetadata = () => { const onLoadedMetadata = () => {
if (destroyed) return; if (destroyed) return;
clearRetryTimer();
videoEl.playbackRate = playbackSpeedRef.current;
if (sourceMode === 'original') {
setSelectedQualityLevel(-2);
}
setBunnyPlaybackState(sourceMode === 'original' ? 'processing' : 'none');
setIsReady(true); setIsReady(true);
setLoadError(false); const resumeState = sourceSwitchResumeRef.current;
setDuration(Number.isFinite(video.duration) ? video.duration : 0); if (resumeState) {
clearRetry(); 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);
sourceSwitchResumeRef.current = null;
if (resumeState.wasPlaying) {
videoEl.play().catch(() => {
// Ignore policy and transient resume-play errors in preview modal.
});
}
}
syncDuration();
attemptAutoplay();
}; };
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false); const onCanPlay = () => {
const onEnded = () => setIsPlaying(false);
const onTimeUpdate = () => setCurrentTime(video.currentTime || 0);
const onError = () => {
if (destroyed) return; if (destroyed) return;
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) { notifyReadyToPlay();
setLoadError(true);
return;
}
setLoadError(false);
if (sourceMode === 'hls' && loadOriginal()) {
return;
}
scheduleRetry(() => {
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(playlistUrl);
video.load();
}
});
}; };
video.addEventListener('loadedmetadata', onLoadedMetadata); const onPlay = () => {
video.addEventListener('play', onPlay); if (destroyed) return;
video.addEventListener('pause', onPause); setIsPlaying(true);
video.addEventListener('ended', onEnded); if (sourceMode !== 'original') {
video.addEventListener('timeupdate', onTimeUpdate); setBunnyPlaybackState('none');
video.addEventListener('error', onError); }
syncDuration();
notifyReadyToPlay();
};
const canPlayNativeHls = video.canPlayType('application/vnd.apple.mpegurl'); const onPause = () => {
if (Hls.isSupported()) { if (destroyed) return;
const hls = new Hls(); setIsPlaying(false);
hlsRef.current = hls; };
usingHlsJs = true;
const onEnded = () => {
if (destroyed) return;
setIsPlaying(false);
};
const onTimeUpdate = () => {
if (destroyed) return;
setCurrentTime(videoEl.currentTime || 0);
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0 && videoEl.duration !== cachedDuration) {
cachedDuration = videoEl.duration;
setDuration(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);
};
const configureHlsLevels = (levels: Level[]) => {
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);
};
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
videoEl.addEventListener('canplay', onCanPlay);
videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('ended', onEnded);
videoEl.addEventListener('timeupdate', onTimeUpdate);
videoEl.addEventListener('error', onVideoError);
if (sourceMode === 'original' && originalUrl) {
retryOriginalLoad();
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
sourceMode = 'hls'; sourceMode = 'hls';
hls.attachMedia(video); videoEl.src = playlistUrl;
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, () => { hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (!destroyed) hls.loadSource(playlistUrl); if (!destroyed) {
hls.loadSource(playlistUrl);
}
}); });
hls.on(Hls.Events.ERROR, (_event, data) => {
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
if (destroyed) return; if (destroyed) return;
if (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) { clearRetryTimer();
if (loadOriginal()) return; setBunnyPlaybackState('none');
scheduleRetry(() => hls.loadSource(getRetryUrl(playlistUrl))); configureHlsLevels(data.levels);
setIsReady(true);
syncDuration();
attemptAutoplay();
});
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 Bunny preview HLS error:', data);
} }
}); });
} else if (canPlayNativeHls) {
sourceMode = 'hls';
video.src = playlistUrl;
video.load();
} else { } else {
// Defer state update to avoid sync setState directly in effect body. setBunnyPlaybackState('error');
window.setTimeout(() => { console.error('HLS is not supported in this browser.');
if (!destroyed) setLoadError(true);
}, 0);
} }
return () => { return () => {
destroyed = true; destroyed = true;
clearRetry(); clearRetryTimer();
video.removeEventListener('loadedmetadata', onLoadedMetadata); videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('play', onPlay); videoEl.removeEventListener('canplay', onCanPlay);
video.removeEventListener('pause', onPause); videoEl.removeEventListener('play', onPlay);
video.removeEventListener('ended', onEnded); videoEl.removeEventListener('pause', onPause);
video.removeEventListener('timeupdate', onTimeUpdate); videoEl.removeEventListener('ended', onEnded);
video.removeEventListener('error', onError); videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('error', onVideoError);
if (hlsRef.current) { if (hlsRef.current) {
hlsRef.current.destroy(); try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null; hlsRef.current = null;
} }
video.removeAttribute('src'); videoEl.removeAttribute('src');
video.load(); videoEl.load();
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setIsReady(false);
}; };
}, [playlistUrl, originalUrl]); }, [notifyReadyToPlay, originalUrl, playlistUrl, bunnySourcePreference, providerVideoId]);
const seekTo = (event: React.MouseEvent<HTMLDivElement>) => { const seekTo = (event: React.MouseEvent<HTMLDivElement>) => {
const video = videoRef.current; const video = videoRef.current;
@@ -223,29 +465,89 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
setIsMuted(nextMuted); setIsMuted(nextMuted);
}, []); }, []);
const handleSpeedChange = useCallback((speed: number) => {
setPlaybackSpeed(speed);
if (videoRef.current) {
videoRef.current.playbackRate = speed;
}
}, []);
const handleQualityChange = useCallback((level: number) => {
const shouldCaptureSourceSwitch = (
(level === -2 && bunnySourcePreference !== 'original')
|| (level !== -2 && bunnySourcePreference === 'original')
);
if (shouldCaptureSourceSwitch) {
const current = videoRef.current?.currentTime ?? 0;
sourceSwitchResumeRef.current = {
time: Number.isFinite(current) ? Math.max(0, current) : 0,
wasPlaying: !!videoRef.current && !videoRef.current.paused,
};
}
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);
}, [bunnySourcePreference]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
togglePlayPause, togglePlayPause,
seekBy, seekBy,
toggleMute, toggleMute,
}), [seekBy, toggleMute, togglePlayPause]); }), [seekBy, toggleMute, togglePlayPause]);
const showProcessingOverlay = bunnyPlaybackState !== 'error' && !isReady;
const showErrorOverlay = bunnyPlaybackState === 'error';
const loadingLabel = isProcessing || bunnyPlaybackState === 'processing'
? 'Processing...'
: 'Loading...';
const selectedQualityLabel = useMemo(() => {
if (selectedQualityLevel === -2) return 'Original';
if (selectedQualityLevel === -1) return 'Auto';
return qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto';
}, [qualityOptions, selectedQualityLevel]);
return ( return (
<div className="w-full h-full rounded-md border overflow-hidden bg-black flex flex-col"> <div className="w-full h-full rounded-md border overflow-hidden bg-black flex flex-col">
<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" />
{(!isReady && !loadError) && ( {showProcessingOverlay && (
<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" />
{isProcessing ? 'Processing...' : 'Loading...'} {loadingLabel}
</div> </div>
</div> </div>
)} )}
{loadError && !isProcessing && ( {showErrorOverlay && (
<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">
<p className="text-xs text-white/85">Unable to load Bunny preview right now.</p> <p className="text-xs text-white/85">Unable to load Bunny preview. Please try again in a moment.</p>
</div> </div>
)} )}
</div> </div>
@@ -273,6 +575,56 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
<span className="text-[11px] text-white/80 tabular-nums ml-1"> <span className="text-[11px] text-white/80 tabular-nums ml-1">
{formatTime(currentTime)} / {formatTime(duration)} {formatTime(currentTime)} / {formatTime(duration)}
</span> </span>
<div className="ml-auto flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-white hover:text-white"
disabled={!isReady}
>
{playbackSpeed}x
<ChevronDown className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{SPEED_OPTIONS.map((speed) => (
<DropdownMenuItem key={speed} onClick={() => handleSpeedChange(speed)}>
{speed}x {speed === playbackSpeed ? '(Current)' : ''}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-white hover:text-white"
disabled={!isReady}
>
{selectedQualityLabel}
<ChevronDown className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleQualityChange(-1)}>
Auto {selectedQualityLevel === -1 ? '(Current)' : ''}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleQualityChange(-2)}>
Original {selectedQualityLevel === -2 ? '(Current)' : ''}
</DropdownMenuItem>
{qualityOptions.map((option) => (
<DropdownMenuItem key={option.level} onClick={() => handleQualityChange(option.level)}>
{option.label} {option.level === selectedQualityLevel ? '(Current)' : ''}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
<div <div
className={cn( className={cn(
@@ -66,6 +66,7 @@ export function useVideoPlayer({
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1); const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto'); const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto');
const pendingHlsQualityRef = useRef<number | null>(null); const pendingHlsQualityRef = useRef<number | null>(null);
const bunnySourceSwitchResumeRef = useRef<{ time: number; wasPlaying: boolean } | null>(null);
const previousVersionKeyRef = useRef<string | 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);
@@ -270,6 +271,7 @@ export function useVideoPlayer({
hlsRef.current = null; hlsRef.current = null;
} }
hlsInstance = null; hlsInstance = null;
setSelectedQualityLevel(-2);
setBunnyPlaybackState('processing'); setBunnyPlaybackState('processing');
setIsReady(false); setIsReady(false);
retryOriginalLoad(); retryOriginalLoad();
@@ -297,11 +299,29 @@ export function useVideoPlayer({
const onLoadedMetadata = () => { const onLoadedMetadata = () => {
if (destroyed) return; if (destroyed) return;
clearRetryTimer(); clearRetryTimer();
if (sourceMode === 'original') {
setSelectedQualityLevel(-2);
}
setBunnyPlaybackState(sourceMode === 'original' ? 'processing' : '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);
} }
setIsReady(true); 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(); syncDuration();
}; };
@@ -776,6 +796,21 @@ export function useVideoPlayer({
); );
const handleQualityChange = useCallback((level: number) => { 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) { if (level === -2) {
pendingHlsQualityRef.current = null; pendingHlsQualityRef.current = null;
setBunnySourcePreference('original'); setBunnySourcePreference('original');
@@ -802,7 +837,7 @@ export function useVideoPlayer({
hls.currentLevel = level; hls.currentLevel = level;
hls.nextLevel = level; hls.nextLevel = level;
setSelectedQualityLevel(level); setSelectedQualityLevel(level);
}, [hlsRef]); }, [activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef]);
const handleTimelineClick = useCallback( const handleTimelineClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => { (e: React.MouseEvent<HTMLDivElement>) => {
+1 -1
View File
@@ -15,7 +15,7 @@
"db:push": "prisma db push", "db:push": "prisma db push",
"db:migrate": "prisma migrate deploy", "db:migrate": "prisma migrate deploy",
"db:seed": "prisma db seed", "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:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run",
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts", "r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts",
"bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run", "bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",