'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import Image from 'next/image'; import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2, UploadCloud, FileVideo, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource, } from '@/lib/video-providers'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import * as tus from 'tus-js-client'; const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv']; function isVideoFile(file: File): boolean { if (file.type.startsWith('video/')) return true; const extension = file.name.split('.').pop()?.toLowerCase(); return !!extension && VIDEO_FILE_EXTENSIONS.includes(extension); } export default function NewVideoPageClient({ projectId, bunnyUploadsEnabled, }: { projectId: string; bunnyUploadsEnabled: boolean; }) { const router = useRouter(); const bunnyCdnHostname = resolvePublicBunnyCdnHostname(); const [isLoading, setIsLoading] = useState(false); const [isFetchingMeta, setIsFetchingMeta] = useState(false); // URL Mode State const [videoUrl, setVideoUrl] = useState(''); const [videoSource, setVideoSource] = useState(null); const [urlError, setUrlError] = useState(''); // Upload Mode State const [uploadMode, setUploadMode] = useState<'url' | 'file'>('url'); const [selectedFile, setSelectedFile] = useState(null); const [uploadProgress, setUploadProgress] = useState(0); const [uploadStatus, setUploadStatus] = useState(''); const [isFileDragOver, setIsFileDragOver] = useState(false); const [pendingBunnyVideoId, setPendingBunnyVideoId] = useState(null); const [pendingBunnyUploadToken, setPendingBunnyUploadToken] = useState(null); const pendingBunnyVideoIdRef = useRef(null); const pendingBunnyUploadTokenRef = useRef(null); const activeTusUploadRef = useRef(null); const fileDragDepthRef = useRef(0); 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(() => { if (!videoSource) return; let cancelled = false; setIsFetchingMeta(true); fetchVideoMetadata(videoSource).then((meta) => { if (cancelled || !meta) { setIsFetchingMeta(false); return; } if (!formData.title) { setFormData((prev) => ({ ...prev, title: meta.title })); } setVideoSource((prev) => (prev ? { ...prev, metadata: meta } : prev)); setIsFetchingMeta(false); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [videoSource?.videoId, videoSource?.providerId]); const handleUrlChange = (url: string) => { setVideoUrl(url); setUrlError(''); setSubmitError(''); if (!url.trim()) { setVideoSource(null); return; } const source = parseVideoUrl(url); if (source) { setVideoSource(source); } else { setVideoSource(null); if (url.length > 10) { setUrlError('Could not recognize this video URL. Currently supported: YouTube, Vimeo'); } } }; const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { if (!isVideoFile(file)) { setSubmitError('Please select a valid video file.'); return; } setSelectedFile(file); setSubmitError(''); if (!formData.title) { // Strip extension from filename for default title const nameWithoutExt = file.name.replace(/\.[^/.]+$/, ''); setFormData((prev) => ({ ...prev, title: nameWithoutExt })); } } }; const setSelectedVideoFile = useCallback( (file: File) => { if (!isVideoFile(file)) { setSubmitError('Please select a valid video file.'); return; } setSelectedFile(file); setSubmitError(''); if (!formData.title) { const nameWithoutExt = file.name.replace(/\.[^/.]+$/, ''); setFormData((prev) => ({ ...prev, title: nameWithoutExt })); } }, [formData.title] ); const handleFileDragEnter = useCallback( (event: React.DragEvent) => { event.preventDefault(); if (isLoading) return; fileDragDepthRef.current += 1; if (Array.from(event.dataTransfer.types).includes('Files')) { setIsFileDragOver(true); } }, [isLoading] ); const handleFileDragOver = useCallback( (event: React.DragEvent) => { event.preventDefault(); if (isLoading) return; event.dataTransfer.dropEffect = 'copy'; if (Array.from(event.dataTransfer.types).includes('Files')) { setIsFileDragOver(true); } }, [isLoading] ); const handleFileDragLeave = useCallback((event: React.DragEvent) => { event.preventDefault(); fileDragDepthRef.current = Math.max(0, fileDragDepthRef.current - 1); if (fileDragDepthRef.current === 0) { setIsFileDragOver(false); } }, []); const handleFileDrop = useCallback( (event: React.DragEvent) => { event.preventDefault(); fileDragDepthRef.current = 0; setIsFileDragOver(false); if (isLoading) return; const file = Array.from(event.dataTransfer.files)[0]; if (!file) return; setSelectedVideoFile(file); }, [isLoading, setSelectedVideoFile] ); 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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: formData.title || file.name }), }); if (!initRes.ok) { const data = await initRes.json(); throw new Error(data.error || 'Failed to initialize upload'); } 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) => { setUploadStatus('Uploading video...'); const upload = new tus.Upload(file, { endpoint: 'https://video.bunnycdn.com/tusupload', retryDelays: [0, 3000, 5000, 10000, 20000], headers: { AuthorizationSignature: signature, AuthorizationExpire: expirationTime.toString(), VideoId: videoId, LibraryId: libraryId, }, metadata: { filetype: file.type, title: formData.title || file.name, }, onError: (error) => { activeTusUploadRef.current = null; reject(new Error('Upload failed: ' + error.message)); }, onProgress: (bytesUploaded, bytesTotal) => { const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1); setUploadProgress(Number(percentage)); setUploadStatus(`Uploading... ${percentage}%`); }, onSuccess: () => { activeTusUploadRef.current = null; setUploadStatus('Processing video...'); resolve({ videoId, libraryId, providerId: 'bunny', url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`, uploadToken, }); }, }); activeTusUploadRef.current = upload; upload.start(); }); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); setSubmitError(''); setUploadStatus(''); setUploadProgress(0); try { let uploadedBunnyVideoId: string | null = null; let uploadedBunnyUploadToken: string | null = null; let finalTitle = formData.title.trim(); const finalDescription = formData.description.trim() || null; let finalVideoUrl = ''; let finalProviderId = ''; let finalVideoId = ''; let finalThumbnailUrl: string | null = null; let finalDuration: number | null = null; if (uploadMode === 'url') { if (!videoSource) { setUrlError('Please enter a valid video URL'); setIsLoading(false); return; } finalTitle = finalTitle || videoSource.metadata?.title || 'Untitled Video'; finalVideoUrl = videoSource.originalUrl; finalProviderId = videoSource.providerId; finalVideoId = videoSource.videoId; finalThumbnailUrl = getThumbnailUrl(videoSource, 'large'); finalDuration = videoSource.metadata?.duration || null; } else { if (!bunnyUploadsEnabled) { throw new Error('Direct uploads are disabled by this host'); } if (!selectedFile) { setSubmitError('Please select a video file to upload'); setIsLoading(false); return; } finalTitle = finalTitle || selectedFile.name; // Handle TUS Upload const bunnyData = await uploadToBunny(selectedFile); uploadedBunnyVideoId = bunnyData.videoId; uploadedBunnyUploadToken = bunnyData.uploadToken; finalVideoUrl = bunnyData.url; finalProviderId = bunnyData.providerId; finalVideoId = bunnyData.videoId; // Bunny will generate thumbnails automatically after processing. // We'll just provide the standard CDN thumbnail URL format as fallback. finalThumbnailUrl = bunnyCdnHostname ? `https://${bunnyCdnHostname}/${bunnyData.videoId}/thumbnail.jpg` : null; } // Final POST to our database const response = await fetch(`/api/projects/${projectId}/videos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: finalTitle, description: finalDescription, videoUrl: finalVideoUrl, providerId: finalProviderId, 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: unknown) { console.error('Failed to add video:', error); 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); } }; const thumbnailUrl = videoSource ? getThumbnailUrl(videoSource, 'large') : null; return (
{ if (!isUploadingFile) return; const shouldLeave = window.confirm(leaveWarningMessage); if (!shouldLeave) { event.preventDefault(); return; } abortAndCleanupPendingUpload(true); }} > Back to Project
Add Video {bunnyUploadsEnabled ? 'Paste a video link or upload a file directly to add it to your project. Currently supports YouTube.' : 'Paste a video link to add it to your project. Direct uploads are disabled on this host.'} !isLoading && setUploadMode(v as 'url' | 'file')} className="mb-6" > Paste URL {bunnyUploadsEnabled ? ( Direct Upload ) : null}
{uploadMode === 'url' ? (
handleUrlChange(e.target.value)} className="pl-10" required disabled={isLoading} />
{urlError && (

{urlError}

)} {videoSource && (

{videoSource.providerId.charAt(0).toUpperCase() + videoSource.providerId.slice(1)}{' '} video detected {isFetchingMeta && ' — fetching metadata...'}

)}
) : (
)} {/* Video Preview (Only for URL mode) */} {uploadMode === 'url' && thumbnailUrl && videoSource && (
Video thumbnail
)} {/* Title */}
setFormData((prev) => ({ ...prev, title: e.target.value }))} disabled={isLoading} />

Leave empty to use the original video title

{/* Description */}