feat: harden Bunny upload flow, migrate Bunny playback to hls.js, and add Bunny storage admin stats

This commit is contained in:
Yusuf İpek
2026-02-22 13:03:45 +03:00
parent 10164069dd
commit e30b4a5b19
17 changed files with 1445 additions and 285 deletions
@@ -1,6 +1,6 @@
'use client';
import { useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import {
@@ -21,8 +21,6 @@ import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { VideoCard } from '@/components/video-card';
type SortOrder = 'desc' | 'asc';
interface SerializedVideo {
id: string;
title: string;
@@ -57,13 +55,17 @@ export function ProjectContentClient({
videos,
canEdit,
isOwner,
workspaceRole,
totalPages,
currentPage
}: ProjectContentClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const sortOrder = searchParams.get('sort') || 'desc';
const [localVideos, setLocalVideos] = useState<SerializedVideo[]>(videos);
useEffect(() => {
setLocalVideos(videos);
}, [videos]);
const createQueryString = useCallback(
(name: string, value: string) => {
@@ -79,12 +81,16 @@ export function ProjectContentClient({
[searchParams]
);
const sortedVideos = [...videos].sort((a, b) => {
const sortedVideos = [...localVideos].sort((a, b) => {
const dateA = new Date(a.updatedAt).getTime();
const dateB = new Date(b.updatedAt).getTime();
return sortOrder === 'desc' ? dateB - dateA : dateA - dateB;
});
const handleVideoDeleted = useCallback((videoId: string) => {
setLocalVideos((prev) => prev.filter((video) => video.id !== videoId));
}, []);
return (
<>
{/* Project Header */}
@@ -171,10 +177,10 @@ export function ProjectContentClient({
</div>
{/* Videos Grid */}
{videos.length > 0 ? (
{localVideos.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sortedVideos.map((video) => (
<VideoCard key={video.id} video={video} projectId={projectId} />
<VideoCard key={video.id} video={video} projectId={projectId} onDeleted={handleVideoDeleted} />
))}
</div>
) : (
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import Hls from 'hls.js';
import Link from 'next/link';
import { useParams, useSearchParams } from 'next/navigation';
import {
@@ -98,6 +99,8 @@ const isSafeUrl = (url: string) => {
}
};
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
export default function CompareVersionsPage() {
const params = useParams();
const searchParams = useSearchParams();
@@ -117,6 +120,8 @@ export default function CompareVersionsPage() {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [cursorIdle, setCursorIdle] = useState(false);
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const timelineRef = useRef<HTMLDivElement>(null);
// Map of versionId -> YT.Player or Custom Adapter
@@ -307,6 +312,57 @@ export default function CompareVersionsPage() {
handleSeek(currentTime);
}, [isDragging, currentTime, handleSeek]);
const handleVideoMouseMove = useCallback(() => {
setCursorIdle(false);
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
}
if (isPlaying) {
cursorIdleTimerRef.current = setTimeout(() => {
setCursorIdle(true);
}, 1000);
}
}, [isPlaying]);
const handleVideoMouseLeave = useCallback(() => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
}
setCursorIdle(false);
}, []);
useEffect(() => {
return () => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
}
};
}, []);
useEffect(() => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
cursorIdleTimerRef.current = null;
}
if (!isPlaying) {
setCursorIdle(false);
return;
}
cursorIdleTimerRef.current = setTimeout(() => {
setCursorIdle(true);
}, 1000);
return () => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
cursorIdleTimerRef.current = null;
}
};
}, [isPlaying]);
// Keyboard shortcuts (matching video page)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -563,9 +619,12 @@ export default function CompareVersionsPage() {
<div
className={cn(
'bg-black flex items-center justify-center relative cursor-pointer group',
cursorIdle && isPlaying && 'cursor-none',
isCommentsOpen ? 'h-[55%]' : 'flex-1'
)}
onClick={handlePlayPause}
onMouseMove={handleVideoMouseMove}
onMouseLeave={handleVideoMouseLeave}
>
{version.providerId === 'youtube' ? (
<YouTubePanel
@@ -595,7 +654,7 @@ export default function CompareVersionsPage() {
<div
className={cn(
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 pointer-events-none',
isPlaying ? 'opacity-0 group-hover:opacity-100' : 'opacity-100'
isPlaying ? (cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100') : 'opacity-100'
)}
>
<div className="w-14 h-14 rounded-full bg-black/60 flex items-center justify-center">
@@ -798,7 +857,7 @@ function YouTubePanel({
return <div ref={containerRef} className="w-full h-full pointer-events-none" />;
}
// Isolated Bunny Steam player component per panel filtering Player.js to YouTube wrapper interface
// Isolated Bunny Stream player component per panel mapped to the shared adapter interface
function BunnyPanel({
version,
onRegister,
@@ -808,86 +867,218 @@ function BunnyPanel({
onRegister: (versionId: string, player: YT.Player | PlayerAdapter) => void;
onUnregister: (versionId: string) => void;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [portraitFrameWidth, setPortraitFrameWidth] = useState<number>(0);
const [isPortraitSource, setIsPortraitSource] = useState(false);
useEffect(() => {
if (!iframeRef.current) return;
const playerjs = require('player.js');
const player = new playerjs.Player(iframeRef.current);
const panelEl = panelRef.current;
if (!panelEl || typeof ResizeObserver === 'undefined') return;
const updateFrameWidth = () => {
const panelWidth = panelEl.clientWidth;
const panelHeight = panelEl.clientHeight;
if (panelWidth <= 0 || panelHeight <= 0) return;
setPortraitFrameWidth(Math.min(panelWidth, panelHeight * (9 / 16)));
};
updateFrameWidth();
const observer = new ResizeObserver(updateFrameWidth);
observer.observe(panelEl);
return () => observer.disconnect();
}, []);
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl) return;
let cachedTime = 0;
let cachedDuration = 0;
let isPlaying = false;
let isMuted = false;
let destroyed = false;
let retryAttempt = 0;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
player.on('ready', () => {
player.getDuration((d: number) => { cachedDuration = d; });
const adapter = {
playVideo: () => player.play(),
pauseVideo: () => player.pause(),
seekTo: (time: number) => { cachedTime = time; player.setCurrentTime(time); },
mute: () => { isMuted = true; player.mute(); },
unMute: () => { isMuted = false; player.unmute(); },
isMuted: () => isMuted,
getCurrentTime: () => cachedTime,
getDuration: () => cachedDuration,
getPlayerState: () => isPlaying ? window.YT?.PlayerState?.PLAYING : window.YT?.PlayerState?.PAUSED,
setPlaybackRate: (rate: number) => {
try {
if (player && typeof player.setPlaybackRate === 'function') {
player.setPlaybackRate(rate);
}
} catch (e) {
console.error('Failed to set playback rate', e);
}
},
destroy: () => {
try {
player.off('ready');
player.off('timeupdate');
player.off('play');
player.off('pause');
player.off('ended');
} catch { }
const clearRetryTimer = () => {
if (!retryTimer) return;
clearTimeout(retryTimer);
retryTimer = null;
};
const getRetryUrl = (baseUrl: string) => {
retryAttempt += 1;
const separator = baseUrl.includes('?') ? '&' : '?';
return `${baseUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
};
const scheduleRetry = (retryFn: () => void) => {
clearRetryTimer();
retryTimer = setTimeout(() => {
if (!destroyed) {
retryFn();
}
};
}, 3000);
};
onRegister(version.id, adapter);
});
const adapter: PlayerAdapter = {
playVideo: () => {
videoEl.play().catch((err) => console.error('Error playing Bunny panel video:', err));
},
pauseVideo: () => videoEl.pause(),
seekTo: (time: number) => {
cachedTime = time;
videoEl.currentTime = time;
},
mute: () => {
videoEl.muted = true;
},
unMute: () => {
videoEl.muted = false;
},
isMuted: () => videoEl.muted,
getCurrentTime: () => videoEl.currentTime || cachedTime,
getDuration: () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
cachedDuration = videoEl.duration;
}
return cachedDuration;
},
getPlayerState: () => (
isPlaying
? (window.YT?.PlayerState?.PLAYING ?? 1)
: (window.YT?.PlayerState?.PAUSED ?? 2)
),
setPlaybackRate: (rate: number) => {
videoEl.playbackRate = rate;
},
destroy: () => {
destroyed = true;
clearRetryTimer();
videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null;
}
videoEl.removeAttribute('src');
videoEl.load();
},
};
player.on('timeupdate', (data: { seconds: number }) => { cachedTime = data.seconds; });
player.on('play', () => { isPlaying = true; });
player.on('pause', () => { isPlaying = false; });
player.on('ended', () => { isPlaying = false; });
const onLoadedMetadata = () => {
clearRetryTimer();
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
cachedDuration = videoEl.duration;
}
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
}
};
const onTimeUpdate = () => { cachedTime = videoEl.currentTime || 0; };
const onPlay = () => { isPlaying = true; };
const onPause = () => { isPlaying = false; };
const onEnded = () => { isPlaying = false; };
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
videoEl.addEventListener('timeupdate', onTimeUpdate);
videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('ended', onEnded);
const hlsUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/playlist.m3u8`;
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
videoEl.src = hlsUrl;
videoEl.load();
} else if (Hls.isSupported()) {
const hls = new Hls();
hlsRef.current = hls;
hls.attachMedia(videoEl);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (!destroyed) {
hls.loadSource(hlsUrl);
}
});
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (destroyed) return;
clearRetryTimer();
});
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) {
scheduleRetry(() => {
const retryUrl = getRetryUrl(hlsUrl);
try {
hls.stopLoad();
} catch {
// ignore stop-load failures; loadSource is the important part
}
hls.loadSource(retryUrl);
hls.startLoad(-1);
});
return;
}
if (data.fatal) {
console.error('Fatal HLS error in compare panel:', data);
}
});
} else {
console.error('HLS is not supported in this browser.');
}
onRegister(version.id, adapter);
return () => {
onUnregister(version.id);
try {
player.off('ready');
player.off('timeupdate');
player.off('play');
player.off('pause');
player.off('ended');
} catch { }
adapter.destroy();
};
}, [version.id, onRegister, onUnregister]);
const src = version.originalUrl.replace('/play/', '/embed/');
const embedSrc = `${src}${src.includes('?') ? '&' : '?'}autoplay=false&controls=false`;
}, [version.id, version.videoId, onRegister, onUnregister]);
return (
<div className="relative w-full h-full group">
<iframe
ref={iframeRef}
src={embedSrc}
width="100%"
height="100%"
className="w-full h-full pointer-events-none border-0"
style={{ pointerEvents: 'none' }}
allow="accelerometer; autoplay; encrypted-media; gyroscope;"
allowFullScreen
/>
<div ref={panelRef} className="relative w-full h-full group flex items-center justify-center bg-black">
<div
className={cn(
'relative flex items-center justify-center bg-black',
isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
)}
style={isPortraitSource && portraitFrameWidth > 0 ? { width: `${portraitFrameWidth}px` } : undefined}
>
<video
ref={videoRef}
className="w-full h-full object-contain pointer-events-none border-0 bg-black"
style={{
pointerEvents: 'none',
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'center',
backgroundColor: 'black',
}}
preload="metadata"
playsInline
/>
</div>
</div>
)
}
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
@@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
import * as tus from 'tus-js-client';
@@ -32,12 +32,100 @@ export default function NewVideoPage() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatus, setUploadStatus] = useState('');
const [pendingBunnyVideoId, setPendingBunnyVideoId] = useState<string | null>(null);
const [pendingBunnyUploadToken, setPendingBunnyUploadToken] = useState<string | null>(null);
const pendingBunnyVideoIdRef = useRef<string | null>(null);
const pendingBunnyUploadTokenRef = useRef<string | null>(null);
const activeTusUploadRef = useRef<tus.Upload | null>(null);
const [submitError, setSubmitError] = useState('');
const [formData, setFormData] = useState({
title: '',
description: '',
});
const isUploadingFile = isLoading && uploadMode === 'file';
const leaveWarningMessage = 'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?';
useEffect(() => {
pendingBunnyVideoIdRef.current = pendingBunnyVideoId;
}, [pendingBunnyVideoId]);
useEffect(() => {
pendingBunnyUploadTokenRef.current = pendingBunnyUploadToken;
}, [pendingBunnyUploadToken]);
const cleanupPendingBunnyVideo = useCallback(async (videoId: string, uploadToken: string, keepalive = false) => {
try {
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId, uploadToken }),
keepalive,
});
} catch (error) {
console.error('Failed to cleanup pending Bunny upload:', error);
} finally {
if (pendingBunnyVideoIdRef.current === videoId) {
pendingBunnyVideoIdRef.current = null;
setPendingBunnyVideoId(null);
}
if (pendingBunnyUploadTokenRef.current === uploadToken) {
pendingBunnyUploadTokenRef.current = null;
setPendingBunnyUploadToken(null);
}
}
}, [projectId]);
const abortAndCleanupPendingUpload = useCallback((keepalive = false) => {
const pendingVideoId = pendingBunnyVideoIdRef.current;
const pendingUploadToken = pendingBunnyUploadTokenRef.current;
if (!pendingVideoId || !pendingUploadToken) return;
if (activeTusUploadRef.current) {
try {
activeTusUploadRef.current.abort(true);
} catch {
// Ignore abort failures; we'll still attempt cleanup.
} finally {
activeTusUploadRef.current = null;
}
}
void cleanupPendingBunnyVideo(pendingVideoId, pendingUploadToken, keepalive);
}, [cleanupPendingBunnyVideo]);
useEffect(() => {
if (!isUploadingFile) return;
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
const handlePageHide = () => {
abortAndCleanupPendingUpload(true);
};
const handlePopState = () => {
const shouldLeave = window.confirm(leaveWarningMessage);
if (!shouldLeave) {
window.history.pushState(null, '', window.location.href);
return;
}
abortAndCleanupPendingUpload(true);
};
window.history.pushState(null, '', window.location.href);
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
window.removeEventListener('popstate', handlePopState);
};
}, [abortAndCleanupPendingUpload, isUploadingFile]);
// Auto-fetch metadata when a valid video source is detected
useEffect(() => {
@@ -102,7 +190,9 @@ export default function NewVideoPage() {
}
};
const uploadToBunny = async (file: File): Promise<{ videoId: string; libraryId: string; providerId: string; url: string }> => {
const uploadToBunny = async (
file: File
): Promise<{ videoId: string; libraryId: string; providerId: string; url: string; uploadToken: string }> => {
// 1. Initialize Bunny Stream upload (creates video & gets signature)
setUploadStatus('Initializing upload...');
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
@@ -116,7 +206,11 @@ export default function NewVideoPage() {
throw new Error(data.error || 'Failed to initialize upload');
}
const { data: { videoId, libraryId, signature, expirationTime } } = await initRes.json();
const { data: { videoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json();
setPendingBunnyVideoId(videoId);
setPendingBunnyUploadToken(uploadToken);
pendingBunnyVideoIdRef.current = videoId;
pendingBunnyUploadTokenRef.current = uploadToken;
// 2. Upload via TUS
return new Promise((resolve, reject) => {
@@ -135,6 +229,7 @@ export default function NewVideoPage() {
title: formData.title || file.name,
},
onError: (error) => {
activeTusUploadRef.current = null;
reject(new Error('Upload failed: ' + error.message));
},
onProgress: (bytesUploaded, bytesTotal) => {
@@ -143,15 +238,18 @@ export default function NewVideoPage() {
setUploadStatus(`Uploading... ${percentage}%`);
},
onSuccess: () => {
activeTusUploadRef.current = null;
setUploadStatus('Processing video...');
resolve({
videoId,
libraryId,
providerId: 'bunny',
url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`
url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`,
uploadToken,
});
},
});
activeTusUploadRef.current = upload;
upload.start();
});
};
@@ -165,8 +263,10 @@ export default function NewVideoPage() {
setUploadProgress(0);
try {
let uploadedBunnyVideoId: string | null = null;
let uploadedBunnyUploadToken: string | null = null;
let finalTitle = formData.title.trim();
let finalDescription = formData.description.trim() || null;
const finalDescription = formData.description.trim() || null;
let finalVideoUrl = '';
let finalProviderId = '';
let finalVideoId = '';
@@ -195,6 +295,8 @@ export default function NewVideoPage() {
// Handle TUS Upload
const bunnyData = await uploadToBunny(selectedFile);
uploadedBunnyVideoId = bunnyData.videoId;
uploadedBunnyUploadToken = bunnyData.uploadToken;
finalVideoUrl = bunnyData.url;
finalProviderId = bunnyData.providerId;
@@ -216,20 +318,32 @@ export default function NewVideoPage() {
videoId: finalVideoId,
thumbnailUrl: finalThumbnailUrl,
duration: finalDuration,
uploadToken: uploadedBunnyUploadToken,
}),
});
if (!response.ok) {
const data = await response.json();
setSubmitError(data.error || 'Failed to add video');
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
await cleanupPendingBunnyVideo(uploadedBunnyVideoId, uploadedBunnyUploadToken);
}
return;
}
pendingBunnyVideoIdRef.current = null;
pendingBunnyUploadTokenRef.current = null;
setPendingBunnyVideoId(null);
setPendingBunnyUploadToken(null);
router.push(`/projects/${projectId}`);
} catch (error: any) {
} catch (error: unknown) {
console.error('Failed to add video:', error);
setSubmitError(error.message || 'An unexpected error occurred');
setSubmitError(error instanceof Error ? error.message : 'An unexpected error occurred');
if (pendingBunnyVideoIdRef.current && pendingBunnyUploadTokenRef.current) {
await cleanupPendingBunnyVideo(pendingBunnyVideoIdRef.current, pendingBunnyUploadTokenRef.current);
}
} finally {
activeTusUploadRef.current = null;
setIsLoading(false);
}
};
@@ -242,6 +356,15 @@ export default function NewVideoPage() {
<Link
href={`/projects/${projectId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
onClick={(event) => {
if (!isUploadingFile) return;
const shouldLeave = window.confirm(leaveWarningMessage);
if (!shouldLeave) {
event.preventDefault();
return;
}
abortAndCleanupPendingUpload(true);
}}
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Project
@@ -256,10 +379,10 @@ export default function NewVideoPage() {
</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={uploadMode} onValueChange={(v) => setUploadMode(v as 'url' | 'file')} className="mb-6">
<Tabs value={uploadMode} onValueChange={(v) => !isLoading && setUploadMode(v as 'url' | 'file')} className="mb-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="url">Paste URL</TabsTrigger>
<TabsTrigger value="file">Direct Upload</TabsTrigger>
<TabsTrigger value="url" disabled={isLoading}>Paste URL</TabsTrigger>
<TabsTrigger value="file" disabled={isLoading}>Direct Upload</TabsTrigger>
</TabsList>
</Tabs>
@@ -385,6 +508,11 @@ export default function NewVideoPage() {
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${uploadProgress}%` }}></div>
</div>
)}
{isUploadingFile && (
<p className="text-xs text-amber-500">
Do not close, refresh, or navigate away while the upload is in progress.
</p>
)}
</div>
)}