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_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
+2 -1
View File
@@ -195,7 +195,8 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
{imgError ? (
<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" />
<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>
) : (
// eslint-disable-next-line @next/next/no-img-element
+97 -91
View File
@@ -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<string, boolean>;
bunnyReadyByAssetId: Record<string, boolean>;
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 (
<div className="space-y-2">
{assets.map((asset) => (
<div
key={asset.id}
id={`asset-card-${asset.id}`}
className={cn(
'rounded-lg border p-2 flex gap-3 transition-colors',
focusedAssetId === asset.id && 'ring-2 ring-primary border-primary/60 bg-primary/5'
)}
>
<button className="shrink-0" onClick={() => onViewAsset(asset)}>
{renderAssetPreview(asset)}
</button>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium truncate">{asset.displayName}</p>
<div className="flex items-center gap-1 shrink-0">
{asset.provider === 'BUNNY' && bunnyProcessingByAssetId[asset.id] ? (
<Badge variant="secondary" className="text-[10px] gap-1">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
Processing
</Badge>
) : null}
{assets.map((asset) => {
const isBunnyProcessing = asset.provider === 'BUNNY'
&& !!bunnyProcessingByAssetId[asset.id]
&& !bunnyReadyByAssetId[asset.id];
return (
<div
key={asset.id}
id={`asset-card-${asset.id}`}
className={cn(
'rounded-lg border p-2 flex gap-3 transition-colors',
focusedAssetId === asset.id && 'ring-2 ring-primary border-primary/60 bg-primary/5'
)}
>
<button className="shrink-0" onClick={() => onViewAsset(asset)}>
{renderAssetPreview(asset)}
</button>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium truncate">{asset.displayName}</p>
<div className="flex items-center gap-1 shrink-0">
{isBunnyProcessing ? (
<Badge variant="secondary" className="text-[10px] gap-1">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
Processing
</Badge>
) : null}
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
{asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} {new Date(asset.createdAt).toLocaleDateString()}
</p>
<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 && (
<p className="text-xs text-muted-foreground">
{asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} {new Date(asset.createdAt).toLocaleDateString()}
</p>
<div className="pt-1 flex items-center gap-1">
<Button
size="icon"
variant="destructive"
variant="outline"
className="h-7 w-7"
title="Delete asset"
aria-label="Delete asset"
disabled={activeDeleteAssetId === asset.id}
onClick={() => onDeleteAsset(asset.id)}
title={asset.kind === 'VIDEO' ? 'Play video' : 'View image'}
aria-label={asset.kind === 'VIDEO' ? 'Play video' : 'View image'}
onClick={() => onViewAsset(asset)}
>
{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>
)}
{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>
))}
);
})}
{hasMoreAssets ? (
<Button
+56 -14
View File
@@ -3,7 +3,7 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import * as tus from 'tus-js-client';
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 { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
@@ -76,7 +76,9 @@ export const AssetsPane = memo(function AssetsPane({
const [isUploadingBunny, setIsUploadingBunny] = useState(false);
const [bunnyProgress, setBunnyProgress] = useState(0);
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>({});
const [bunnyReadyByAssetId, setBunnyReadyByAssetId] = useState<Record<string, boolean>>({});
const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState<Record<string, number>>({});
const [bunnyThumbnailLoadErrorByAssetId, setBunnyThumbnailLoadErrorByAssetId] = useState<Record<string, boolean>>({});
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
@@ -217,6 +219,12 @@ export const AssetsPane = memo(function AssetsPane({
};
}, [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) => {
if (!file) return;
@@ -373,6 +381,8 @@ export const AssetsPane = memo(function AssetsPane({
if (!createdAsset) {
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 = '';
setBunnyTitle('');
} catch (error) {
@@ -392,13 +402,25 @@ export const AssetsPane = memo(function AssetsPane({
};
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(() => {
setBunnyThumbnailRetryKeyByAssetId((prev) => ({ ...prev, [assetId]: Date.now() }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [assetId]: false }));
setBunnyThumbnailLoadErrorByAssetId((prev) => ({ ...prev, [assetId]: false }));
}, 10000);
};
const handleBunnyThumbnailLoad = (assetId: string) => {
setBunnyThumbnailLoadErrorByAssetId((prev) => {
if (!prev[assetId]) return prev;
return { ...prev, [assetId]: false };
});
};
const renderAssetPreview = (asset: VideoAsset) => {
if (asset.kind === 'IMAGE') {
const imageSrc = asset.thumbnailUrl || asset.sourceUrl;
@@ -429,25 +451,36 @@ export const AssetsPane = memo(function AssetsPane({
const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0;
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 showThumbnailImage = !!thumbnailSrc && !hasThumbnailLoadError;
return (
<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
<img
src={thumbnailSrc}
alt={asset.displayName}
alt=""
className="h-full w-full object-cover"
onLoad={() => handleBunnyThumbnailLoad(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" />
)}
{isProcessing && (
{isProcessing && !isReadyToPlay && (
<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" />
<span className="text-[10px] text-white/90 font-medium">Processing...</span>
<span className="text-[10px] text-white/90 font-medium">
Processing...
</span>
</div>
)}
</div>
@@ -455,11 +488,6 @@ export const AssetsPane = memo(function AssetsPane({
};
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.sourceUrl) {
toast.error('Preview is unavailable for this asset');
@@ -469,9 +497,17 @@ export const AssetsPane = memo(function AssetsPane({
setPreviewImageTitle(asset.displayName);
return;
}
if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) {
setBunnyProcessingByAssetId((prev) => (prev[asset.id] ? prev : { ...prev, [asset.id]: true }));
}
setSelectedAsset(asset);
};
const selectedBunnyAssetId = selectedAsset?.provider === 'BUNNY' ? selectedAsset.id : null;
const isSelectedBunnyProcessing = selectedBunnyAssetId
? !!bunnyProcessingByAssetId[selectedBunnyAssetId] && !bunnyReadyByAssetId[selectedBunnyAssetId]
: false;
return (
<div className="space-y-4" onPaste={handleImagePaste}>
<div className="flex items-center justify-between">
@@ -608,6 +644,7 @@ export const AssetsPane = memo(function AssetsPane({
isLoadingAssets={isLoadingAssets}
focusedAssetId={focusedAssetId}
bunnyProcessingByAssetId={bunnyProcessingByAssetId}
bunnyReadyByAssetId={bunnyReadyByAssetId}
activeDownloadAssetId={activeDownloadAssetId}
activeDeleteAssetId={activeDeleteAssetId}
canDownloadAssets={canDownloadAssets}
@@ -678,7 +715,7 @@ export const AssetsPane = memo(function AssetsPane({
aria-label="Download Bunny video"
disabled={
activeDownloadAssetId === selectedAsset.id
|| !!bunnyProcessingByAssetId[selectedAsset.id]
|| isSelectedBunnyProcessing
}
>
{activeDownloadAssetId === selectedAsset.id ? (
@@ -729,7 +766,12 @@ export const AssetsPane = memo(function AssetsPane({
<BunnyPreviewPlayer
ref={bunnyPreviewPlayerRef}
providerVideoId={selectedAsset.providerVideoId}
isProcessing={!!bunnyProcessingByAssetId[selectedAsset.id]}
isProcessing={isSelectedBunnyProcessing}
onReadyToPlay={() => {
if (!selectedBunnyAssetId) return;
setBunnyReadyByAssetId((prev) => ({ ...prev, [selectedBunnyAssetId]: true }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [selectedBunnyAssetId]: false }));
}}
/>
)
) : null}
+452 -100
View File
@@ -1,14 +1,23 @@
'use client';
/* eslint-disable react-hooks/set-state-in-effect */
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import Hls from 'hls.js';
import { Loader2, Pause, Play, Volume2, VolumeX } from 'lucide-react';
import Hls, { type Level } from 'hls.js';
import { ChevronDown, Loader2, Pause, Play, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { BunnyPlaybackState, BunnyQualityOption } from '@/components/video-page/types';
interface BunnyPreviewPlayerProps {
providerVideoId: string | null;
isProcessing: boolean;
onReadyToPlay?: () => void;
}
export interface BunnyPreviewPlayerHandle {
@@ -17,16 +26,16 @@ export interface BunnyPreviewPlayerHandle {
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;
if (!configured) return DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
if (!configured) return null;
try {
const parsed = new URL(configured);
return parsed.hostname || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
return parsed.hostname || null;
} 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')}`;
}
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 hlsRef = useRef<Hls | 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 [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = 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(() => {
if (!providerVideoId) return null;
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/playlist.m3u8`;
}, [providerVideoId]);
if (!providerVideoId || !bunnyCdnHostname) return null;
return `https://${bunnyCdnHostname}/${providerVideoId}/playlist.m3u8`;
}, [bunnyCdnHostname, providerVideoId]);
const originalUrl = useMemo(() => {
if (!providerVideoId) return null;
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/original`;
}, [providerVideoId]);
if (!providerVideoId || !bunnyCdnHostname) return null;
return `https://${bunnyCdnHostname}/${providerVideoId}/original`;
}, [bunnyCdnHostname, providerVideoId]);
useEffect(() => {
const video = videoRef.current;
if (!video || !playlistUrl) return;
onReadyToPlayRef.current = onReadyToPlay;
}, [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 retryAttempt = 0;
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) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
@@ -76,121 +153,286 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
};
const scheduleRetry = (retryFn: () => void) => {
clearRetry();
clearRetryTimer();
retryTimerRef.current = setTimeout(() => {
if (!destroyed) retryFn();
if (!destroyed) {
retryFn();
}
}, 3000);
};
const getRetryUrl = (baseUrl: string) => {
retryAttemptRef.current += 1;
retryAttempt += 1;
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;
sourceMode = 'original';
usingHlsJs = false;
clearRetryTimer();
if (hlsRef.current) {
hlsRef.current.destroy();
try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null;
}
video.src = getRetryUrl(originalUrl);
video.load();
hlsInstance = null;
setSelectedQualityLevel(-2);
setBunnyPlaybackState('processing');
setIsReady(false);
retryOriginalLoad();
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 = () => {
if (destroyed) return;
clearRetryTimer();
videoEl.playbackRate = playbackSpeedRef.current;
if (sourceMode === 'original') {
setSelectedQualityLevel(-2);
}
setBunnyPlaybackState(sourceMode === 'original' ? 'processing' : 'none');
setIsReady(true);
setLoadError(false);
setDuration(Number.isFinite(video.duration) ? video.duration : 0);
clearRetry();
const resumeState = sourceSwitchResumeRef.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);
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 onEnded = () => setIsPlaying(false);
const onTimeUpdate = () => setCurrentTime(video.currentTime || 0);
const onError = () => {
const onCanPlay = () => {
if (destroyed) return;
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
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();
}
});
notifyReadyToPlay();
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('play', onPlay);
video.addEventListener('pause', onPause);
video.addEventListener('ended', onEnded);
video.addEventListener('timeupdate', onTimeUpdate);
video.addEventListener('error', onError);
const onPlay = () => {
if (destroyed) return;
setIsPlaying(true);
if (sourceMode !== 'original') {
setBunnyPlaybackState('none');
}
syncDuration();
notifyReadyToPlay();
};
const canPlayNativeHls = video.canPlayType('application/vnd.apple.mpegurl');
if (Hls.isSupported()) {
const hls = new Hls();
hlsRef.current = hls;
usingHlsJs = true;
const onPause = () => {
if (destroyed) return;
setIsPlaying(false);
};
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';
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, () => {
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 (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) {
if (loadOriginal()) return;
scheduleRetry(() => hls.loadSource(getRetryUrl(playlistUrl)));
clearRetryTimer();
setBunnyPlaybackState('none');
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 {
// Defer state update to avoid sync setState directly in effect body.
window.setTimeout(() => {
if (!destroyed) setLoadError(true);
}, 0);
setBunnyPlaybackState('error');
console.error('HLS is not supported in this browser.');
}
return () => {
destroyed = true;
clearRetry();
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('play', onPlay);
video.removeEventListener('pause', onPause);
video.removeEventListener('ended', onEnded);
video.removeEventListener('timeupdate', onTimeUpdate);
video.removeEventListener('error', onError);
clearRetryTimer();
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
videoEl.removeEventListener('canplay', onCanPlay);
videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('error', onVideoError);
if (hlsRef.current) {
hlsRef.current.destroy();
try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null;
}
video.removeAttribute('src');
video.load();
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setIsReady(false);
videoEl.removeAttribute('src');
videoEl.load();
};
}, [playlistUrl, originalUrl]);
}, [notifyReadyToPlay, originalUrl, playlistUrl, bunnySourcePreference, providerVideoId]);
const seekTo = (event: React.MouseEvent<HTMLDivElement>) => {
const video = videoRef.current;
@@ -223,29 +465,89 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
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, () => ({
togglePlayPause,
seekBy,
toggleMute,
}), [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 (
<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}>
<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="flex items-center gap-2 text-white text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
{isProcessing ? 'Processing...' : 'Loading...'}
{loadingLabel}
</div>
</div>
)}
{loadError && !isProcessing && (
{showErrorOverlay && (
<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>
@@ -273,6 +575,56 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
<span className="text-[11px] text-white/80 tabular-nums ml-1">
{formatTime(currentTime)} / {formatTime(duration)}
</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
className={cn(
@@ -66,6 +66,7 @@ export function useVideoPlayer({
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto');
const pendingHlsQualityRef = useRef<number | null>(null);
const bunnySourceSwitchResumeRef = useRef<{ time: number; wasPlaying: boolean } | null>(null);
const previousVersionKeyRef = useRef<string | null>(null);
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(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<HTMLDivElement>) => {
+1 -1
View File
@@ -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",