mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user