'use client'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as tus from 'tus-js-client'; import { toast } from 'sonner'; import { toastApiError } from '@/lib/client/api-error'; import { Download, FileVideo, Image as ImageIcon, Loader2, Mic, Pause, Play, Square, UploadCloud, Volume2, X, Youtube, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ImagePreviewDialog } from '@/components/video-page/image-preview-dialog'; import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle, } from '@/components/video-page/bunny-preview-player'; import { AssetListSection } from '@/components/video-page/asset-list-section'; import type { AssetDownloadPreference, DirectUploadProvider, VideoAsset, } from '@/components/video-page/types'; import { uploadAssetVideoToR2 } from '@/lib/client/r2-asset-video-upload'; import { extractPastedImageFiles, validateImageFile, } from '@/components/video-page/image-upload-utils'; import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media'; import { withWebmDuration } from '@/lib/webm-duration'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { cn } from '@/lib/utils'; const MAX_AUDIO_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB const MAX_AUDIO_UPLOAD_SIZE_MESSAGE = 'File too large. Maximum size is 10MB.'; function formatTime(seconds: number): string { const s = Math.floor(seconds); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; } type UploadAudioResponse = { data?: { url?: string; reservationId?: string | null }; error?: string; code?: string; }; function getAudioUploadValidationError(file: Blob): string | null { if (file.size > MAX_AUDIO_UPLOAD_SIZE) { return MAX_AUDIO_UPLOAD_SIZE_MESSAGE; } return null; } async function readUploadAudioResponse(response: Response): Promise { const raw = await response.text().catch(() => ''); const trimmed = raw.trim(); if (!trimmed) return null; try { return JSON.parse(trimmed) as UploadAudioResponse; } catch { if (trimmed.startsWith('<')) return null; return { error: trimmed }; } } interface AssetsPaneProps { videoId: string; assets: VideoAsset[]; isLoadingAssets: boolean; isCreatingAsset: boolean; deletingAssetIds: string[]; activeDownloadAssetId: string | null; canUploadAssets: boolean; canDownloadAssets: boolean; getGuestUploadToken: (intent: 'image' | 'audio') => Promise; createAsset: (payload: { provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO'; displayName?: string; sourceUrl: string; providerVideoId?: string; thumbnailUrl?: string; uploadToken?: string; objectKey?: string; reservationId?: string | null; }) => Promise; deleteAsset: (assetId: string) => Promise; downloadAsset: (asset: VideoAsset, preference?: AssetDownloadPreference) => Promise; hasMoreAssets: boolean; isLoadingMoreAssets: boolean; loadMoreAssets: () => Promise; highlightedAssetId: string | null; onHighlightedAssetHandled: () => void; directUploadProvider?: DirectUploadProvider; } export const AssetsPane = memo(function AssetsPane({ videoId, assets, isLoadingAssets, isCreatingAsset, deletingAssetIds, activeDownloadAssetId, canUploadAssets, canDownloadAssets, getGuestUploadToken, createAsset, deleteAsset, downloadAsset, hasMoreAssets, isLoadingMoreAssets, loadMoreAssets, highlightedAssetId, onHighlightedAssetHandled, directUploadProvider = 'bunny', }: AssetsPaneProps) { const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny' | 'voice'>('image'); const [imageTitle, setImageTitle] = useState(''); const [pendingImageFiles, setPendingImageFiles] = useState([]); const [youtubeUrl, setYoutubeUrl] = useState(''); const [youtubeTitle, setYoutubeTitle] = useState(''); const [bunnyTitle, setBunnyTitle] = useState(''); const [isUploadingImage, setIsUploadingImage] = useState(false); const [isUploadingBunny, setIsUploadingBunny] = useState(false); const [bunnyProgress, setBunnyProgress] = useState(0); const [bunnyUploadLabel, setBunnyUploadLabel] = useState(''); const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState>( {} ); const [bunnyReadyByAssetId, setBunnyReadyByAssetId] = useState>({}); const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState< Record >({}); const [bunnyThumbnailLoadErrorByAssetId, setBunnyThumbnailLoadErrorByAssetId] = useState< Record >({}); const [previewImage, setPreviewImage] = useState(null); const [previewImageTitle, setPreviewImageTitle] = useState(null); const [selectedAsset, setSelectedAsset] = useState(null); const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []); const [focusedAssetId, setFocusedAssetId] = useState(null); const bunnyPreviewPlayerRef = useRef(null); const youtubeIframeRef = useRef(null); const youtubePreviewStateRef = useRef({ currentTime: 0, isPlaying: false, isMuted: false }); const imageInputRef = useRef(null); const bunnyInputRef = useRef(null); const voiceInputRef = useRef(null); // Voice recording state const [voiceTitle, setVoiceTitle] = useState(''); const [isRecording, setIsRecording] = useState(false); const [recordingTime, setRecordingTime] = useState(0); const [audioBlob, setAudioBlob] = useState(null); const [audioBlobUrl, setAudioBlobUrl] = useState(null); const [pendingAudioFiles, setPendingAudioFiles] = useState([]); const [isUploadingVoice, setIsUploadingVoice] = useState(false); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const recordingTimerRef = useRef | null>(null); const recordingStartedAtRef = useRef(0); // Drag-drop state const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); // Audio playback for asset preview dialog and recording preview const { playingVoiceId, voiceProgress, voiceCurrentTime, voicePlaybackRate, playVoice, stopVoice, toggleVoiceSpeed, } = useCommentMedia(); const sortedAssets = useMemo(() => { return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)); }, [assets]); useEffect(() => { if (!highlightedAssetId) return; const element = document.getElementById(`asset-card-${highlightedAssetId}`); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); setFocusedAssetId(highlightedAssetId); window.setTimeout( () => setFocusedAssetId((prev) => (prev === highlightedAssetId ? null : prev)), 2500 ); } onHighlightedAssetHandled(); }, [highlightedAssetId, onHighlightedAssetHandled]); useEffect(() => { if (!selectedAsset || selectedAsset.kind !== 'VIDEO') return; const sendYouTubeCommand = (func: string, args: unknown[] = []) => { const iframe = youtubeIframeRef.current; if (!iframe?.contentWindow) return; iframe.contentWindow.postMessage( JSON.stringify({ event: 'command', func, args, }), '*' ); }; const onMessage = (event: MessageEvent) => { if (!selectedAsset || selectedAsset.provider !== 'YOUTUBE') return; if (typeof event.data !== 'string') return; let parsed: unknown; try { parsed = JSON.parse(event.data); } catch { return; } const info = ( parsed as { info?: { currentTime?: number; playerState?: number; muted?: boolean } } )?.info; if (!info) return; if (typeof info.currentTime === 'number') { youtubePreviewStateRef.current.currentTime = info.currentTime; } if (typeof info.playerState === 'number') { youtubePreviewStateRef.current.isPlaying = info.playerState === 1; } if (typeof info.muted === 'boolean') { youtubePreviewStateRef.current.isMuted = info.muted; } }; const onKeyDown = (event: KeyboardEvent) => { if (!selectedAsset || selectedAsset.kind !== 'VIDEO') return; const target = event.target as HTMLElement | null; if ( target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) ) return; const handledKeys = new Set([ 'Space', 'KeyK', 'ArrowLeft', 'ArrowRight', 'KeyJ', 'KeyL', 'KeyM', 'Escape', ]); if (!handledKeys.has(event.code)) return; event.preventDefault(); event.stopPropagation(); if (event.code === 'Escape') { setSelectedAsset(null); return; } if (selectedAsset.provider === 'BUNNY') { switch (event.code) { case 'Space': case 'KeyK': bunnyPreviewPlayerRef.current?.togglePlayPause(); break; case 'ArrowLeft': case 'KeyJ': bunnyPreviewPlayerRef.current?.seekBy(-10); break; case 'ArrowRight': case 'KeyL': bunnyPreviewPlayerRef.current?.seekBy(10); break; case 'KeyM': bunnyPreviewPlayerRef.current?.toggleMute(); break; } return; } if (selectedAsset.provider === 'YOUTUBE') { switch (event.code) { case 'Space': case 'KeyK': { const isPlaying = youtubePreviewStateRef.current.isPlaying; sendYouTubeCommand(isPlaying ? 'pauseVideo' : 'playVideo'); youtubePreviewStateRef.current.isPlaying = !isPlaying; break; } case 'ArrowLeft': case 'KeyJ': { const next = Math.max(0, youtubePreviewStateRef.current.currentTime - 10); sendYouTubeCommand('seekTo', [next, true]); youtubePreviewStateRef.current.currentTime = next; break; } case 'ArrowRight': case 'KeyL': { const next = youtubePreviewStateRef.current.currentTime + 10; sendYouTubeCommand('seekTo', [next, true]); youtubePreviewStateRef.current.currentTime = next; break; } case 'KeyM': { const isMuted = youtubePreviewStateRef.current.isMuted; sendYouTubeCommand(isMuted ? 'unMute' : 'mute'); youtubePreviewStateRef.current.isMuted = !isMuted; break; } } } }; window.addEventListener('keydown', onKeyDown, true); window.addEventListener('message', onMessage); return () => { window.removeEventListener('keydown', onKeyDown, true); window.removeEventListener('message', onMessage); }; }, [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 uploadSingleImageAsset = useCallback( async (file: File, displayName?: string): Promise => { const imageError = await validateImageFile(file); if (imageError) { toast.error(`${file.name}: ${imageError}`); return false; } try { const formData = new FormData(); formData.append('image', file); formData.append('videoId', videoId); const guestUploadToken = await getGuestUploadToken('image'); if (guestUploadToken) formData.append('uploadToken', guestUploadToken); const uploadRes = await fetch('/api/upload/image', { method: 'POST', body: formData, }); const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string; reservationId?: string | null }; error?: string; code?: string; } | null; const uploadedImageUrl = uploadPayload?.data?.url; if (!uploadRes.ok || !uploadedImageUrl) { toastApiError(uploadPayload, 'Failed to upload image', { prefix: file.name }); return false; } const created = await createAsset({ provider: 'R2_IMAGE', sourceUrl: uploadedImageUrl, displayName: displayName?.trim() || file.name, reservationId: uploadPayload?.data?.reservationId ?? null, }); return !!created; } catch (error) { console.error('Failed to upload image asset:', error); toastApiError(error, 'Failed to upload image', { prefix: file.name }); return false; } }, [videoId, getGuestUploadToken, createAsset] ); const handleImageUpload = useCallback( async (files: File[]) => { if (files.length === 0) return; setIsUploadingImage(true); let successCount = 0; let failCount = 0; try { for (const file of files) { const displayName = imageTitle.trim() || file.name; const ok = await uploadSingleImageAsset(file, displayName); if (ok) successCount += 1; else failCount += 1; } if (successCount > 0) { if (imageInputRef.current) imageInputRef.current.value = ''; setImageTitle(''); setPendingImageFiles([]); } if (successCount > 0 && failCount === 0) { toast.success(successCount === 1 ? 'Image uploaded' : `${successCount} images uploaded`); } else if (successCount > 0 && failCount > 0) { toast.warning(`${successCount} uploaded, ${failCount} failed`); } } finally { setIsUploadingImage(false); } }, [imageTitle, uploadSingleImageAsset] ); const stageImageFiles = useCallback(async (files: File[]) => { const validFiles: File[] = []; for (const file of files) { const imageError = await validateImageFile(file); if (imageError) { toast.error(`${file.name}: ${imageError}`); continue; } validFiles.push(file); } if (validFiles.length === 0) return; setPendingImageFiles((prev) => { const next = [...prev]; for (const file of validFiles) { const duplicate = next.some( (existing) => existing.name === file.name && existing.size === file.size && existing.lastModified === file.lastModified ); if (!duplicate) next.push(file); } return next; }); toast.success( validFiles.length === 1 ? 'Image attached. Click Upload to send.' : `${validFiles.length} images attached. Click Upload to send.` ); }, []); const handleImageFileChange = async (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); if (files.length === 0) return; setUploadTab('image'); await stageImageFiles(files); if (imageInputRef.current) imageInputRef.current.value = ''; }; const handleImagePaste = async (event: React.ClipboardEvent) => { if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return; const pastedImages = extractPastedImageFiles(event.clipboardData); if (pastedImages.length === 0) return; event.preventDefault(); await stageImageFiles(pastedImages); }; const handleCreateYoutubeAsset = async () => { if (!youtubeUrl.trim()) return; const created = await createAsset({ provider: 'YOUTUBE', sourceUrl: youtubeUrl.trim(), displayName: youtubeTitle.trim() || undefined, }); if (created) { setYoutubeUrl(''); setYoutubeTitle(''); } }; const handleBunnyFileUpload = useCallback( async (file: File, options?: { index?: number; total?: number }) => { if (!file.type.startsWith('video/')) { toast.error(`${file.name}: Please select a video file`); return false; } let uploadedVideoId: string | null = null; let uploadToken: string | null = null; try { setIsUploadingBunny(true); setBunnyProgress(0); if (options?.total && options.total > 1) { setBunnyUploadLabel(`Uploading ${options.index ?? 1} of ${options.total}: ${file.name}`); } else { setBunnyUploadLabel(''); } const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: bunnyTitle.trim() || file.name.replace(/\.[^/.]+$/, ''), sizeBytes: file.size.toString(), }), }); const initPayload = (await initRes.json().catch(() => null)) as { data?: { videoId: string; libraryId: string; signature: string; expirationTime: number; uploadToken: string; }; error?: string; code?: string; } | null; if (!initRes.ok || !initPayload?.data) { toastApiError(initPayload, 'Failed to initialize Bunny upload', { prefix: file.name }); return false; } const initData = initPayload.data; uploadedVideoId = initData.videoId; uploadToken = initData.uploadToken; await new Promise((resolve, reject) => { const upload = new tus.Upload(file, { endpoint: 'https://video.bunnycdn.com/tusupload', retryDelays: [0, 3000, 5000, 10000, 20000], headers: { AuthorizationSignature: initData.signature, AuthorizationExpire: initData.expirationTime.toString(), VideoId: initData.videoId, LibraryId: initData.libraryId, }, metadata: { filetype: file.type, title: file.name, }, onError: (error) => reject(error), onProgress: (bytesUploaded, bytesTotal) => { const percentage = bytesTotal > 0 ? (bytesUploaded / bytesTotal) * 100 : 0; setBunnyProgress(Math.min(100, Math.max(0, percentage))); }, onSuccess: () => resolve(), }); upload.start(); }); const sourceUrl = `https://iframe.mediadelivery.net/embed/${initData.libraryId}/${initData.videoId}`; const thumbnailUrl = bunnyCdnHostname ? `https://${bunnyCdnHostname}/${initData.videoId}/thumbnail.jpg` : undefined; const createdAsset = await createAsset({ provider: 'BUNNY', sourceUrl, providerVideoId: initData.videoId, uploadToken: initData.uploadToken, thumbnailUrl, displayName: bunnyTitle.trim() || file.name, }); if (!createdAsset) { throw new Error('Failed to finalize Bunny asset'); } setBunnyReadyByAssetId((prev) => ({ ...prev, [createdAsset.id]: false })); setBunnyProcessingByAssetId((prev) => ({ ...prev, [createdAsset.id]: true })); return true; } catch (error) { console.error('Failed to upload Bunny asset:', error); toastApiError(error, 'Failed to upload Bunny video', { prefix: file.name }); if (uploadedVideoId && uploadToken) { await fetch(`/api/videos/${videoId}/assets/bunny-init`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ videoId: uploadedVideoId, uploadToken }), }).catch(() => undefined); } return false; } finally { setIsUploadingBunny(false); setBunnyProgress(0); setBunnyUploadLabel(''); } }, [videoId, bunnyTitle, bunnyCdnHostname, createAsset] ); const handleR2FileUpload = useCallback( async (file: File, options?: { index?: number; total?: number }) => { if (!file.type.startsWith('video/')) { toast.error(`${file.name}: Please select a video file`); return false; } try { setIsUploadingBunny(true); setBunnyProgress(0); if (options?.total && options.total > 1) { setBunnyUploadLabel(`Uploading ${options.index ?? 1} of ${options.total}: ${file.name}`); } else { setBunnyUploadLabel(''); } const uploadResult = await uploadAssetVideoToR2(videoId, file, { onProgress: (progress) => setBunnyProgress(progress), }); const createdAsset = await createAsset({ provider: 'R2_VIDEO', sourceUrl: uploadResult.proxyUrl, objectKey: uploadResult.objectKey, uploadToken: uploadResult.uploadToken, reservationId: uploadResult.reservationId, thumbnailUrl: uploadResult.thumbnailUrl ?? undefined, displayName: bunnyTitle.trim() || file.name, }); if (!createdAsset) { throw new Error('Failed to finalize video asset'); } return true; } catch (error) { console.error('Failed to upload R2 asset video:', error); toastApiError(error, 'Failed to upload video', { prefix: file.name }); return false; } finally { setIsUploadingBunny(false); setBunnyProgress(0); setBunnyUploadLabel(''); } }, [videoId, bunnyTitle, createAsset] ); const handleVideoFileUpload = useCallback( (file: File, options?: { index?: number; total?: number }) => { if (directUploadProvider === 'r2') { return handleR2FileUpload(file, options); } return handleBunnyFileUpload(file, options); }, [directUploadProvider, handleBunnyFileUpload, handleR2FileUpload] ); const handleVideoBatchUpload = useCallback( async (files: File[]) => { if (files.length === 0) return; let successCount = 0; let failCount = 0; for (let index = 0; index < files.length; index++) { const ok = await handleVideoFileUpload(files[index], { index: index + 1, total: files.length, }); if (ok) successCount += 1; else failCount += 1; } if (bunnyInputRef.current) bunnyInputRef.current.value = ''; if (successCount > 0) setBunnyTitle(''); if (successCount > 0 && failCount === 0) { toast.success(successCount === 1 ? 'Video uploaded' : `${successCount} videos uploaded`); } else if (successCount > 0 && failCount > 0) { toast.warning(`${successCount} uploaded, ${failCount} failed`); } }, [handleVideoFileUpload] ); const handleBunnyUpload = async (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []).filter((file) => file.type.startsWith('video/') ); if (files.length === 0) { toast.error('Please select valid video files'); return; } await handleVideoBatchUpload(files); }; const handleBunnyThumbnailError = (assetId: string) => { const alreadyReady = !!bunnyReadyByAssetId[assetId]; setBunnyThumbnailLoadErrorByAssetId((prev) => ({ ...prev, [assetId]: true })); if (!alreadyReady) { setBunnyProcessingByAssetId((prev) => (prev[assetId] ? prev : { ...prev, [assetId]: true })); setBunnyReadyByAssetId((prev) => ({ ...prev, [assetId]: false })); } window.setTimeout(() => { setBunnyThumbnailRetryKeyByAssetId((prev) => ({ ...prev, [assetId]: Date.now() })); setBunnyThumbnailLoadErrorByAssetId((prev) => ({ ...prev, [assetId]: false })); }, 10000); }; const handleBunnyThumbnailLoad = (assetId: string) => { setBunnyThumbnailLoadErrorByAssetId((prev) => { if (!prev[assetId]) return prev; return { ...prev, [assetId]: false }; }); }; const startRecording = useCallback(async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'; const recorder = new MediaRecorder(stream, { mimeType }); audioChunksRef.current = []; recorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); }; recorder.onstop = async () => { const elapsedMs = Date.now() - recordingStartedAtRef.current; const raw = new Blob(audioChunksRef.current, { type: mimeType }); // MediaRecorder leaves the WebM duration unset, so stamp it in before the // blob reaches a player or the upload. const blob = await withWebmDuration(raw, elapsedMs); setRecordingTime(elapsedMs / 1000); setAudioBlob(blob); setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(blob); }); stream.getTracks().forEach((t) => t.stop()); }; mediaRecorderRef.current = recorder; recorder.start(100); setIsRecording(true); setRecordingTime(0); // Background tabs throttle timers, so read the clock instead of counting ticks. recordingStartedAtRef.current = Date.now(); recordingTimerRef.current = setInterval( () => setRecordingTime((Date.now() - recordingStartedAtRef.current) / 1000), 250 ); } catch { toast.error('Could not access microphone'); } }, []); const stopRecording = useCallback(() => { mediaRecorderRef.current?.stop(); mediaRecorderRef.current = null; setIsRecording(false); if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); recordingTimerRef.current = null; } }, []); const cancelRecording = useCallback(() => { mediaRecorderRef.current?.stop(); mediaRecorderRef.current?.stream?.getTracks().forEach((t) => t.stop()); mediaRecorderRef.current = null; setIsRecording(false); setRecordingTime(0); setAudioBlob(null); setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); setPendingAudioFiles([]); if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); recordingTimerRef.current = null; } }, []); const uploadSingleAudioAsset = useCallback( async (file: File | Blob, fileName: string, displayName?: string): Promise => { const validationError = getAudioUploadValidationError(file); if (validationError) { toast.error(`${fileName}: ${validationError}`); return false; } try { const formData = new FormData(); formData.append('audio', file, file instanceof File ? file.name : 'recording.webm'); formData.append('videoId', videoId); const guestUploadToken = await getGuestUploadToken('audio'); if (guestUploadToken) formData.append('uploadToken', guestUploadToken); const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData, }); const uploadPayload = await readUploadAudioResponse(uploadRes); const uploadedUrl = uploadPayload?.data?.url; if (!uploadRes.ok || !uploadedUrl) { toastApiError( uploadPayload, uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : 'Failed to upload voice recording', { prefix: fileName } ); return false; } const created = await createAsset({ provider: 'R2_AUDIO', sourceUrl: uploadedUrl, displayName: displayName?.trim() || fileName.replace(/\.[^/.]+$/, '') || 'Voice Recording', reservationId: uploadPayload?.data?.reservationId ?? null, }); return !!created; } catch (error) { console.error('Failed to upload voice asset:', error); toastApiError(error, 'Failed to upload voice recording', { prefix: fileName }); return false; } }, [videoId, getGuestUploadToken, createAsset] ); const stageAudioFiles = useCallback((files: File[]) => { const validFiles: File[] = []; for (const file of files) { const audioError = getAudioUploadValidationError(file); if (audioError) { toast.error(`${file.name}: ${audioError}`); continue; } validFiles.push(file); } if (validFiles.length === 0) return; setPendingAudioFiles((prev) => { const next = [...prev]; for (const file of validFiles) { const duplicate = next.some( (existing) => existing.name === file.name && existing.size === file.size && existing.lastModified === file.lastModified ); if (!duplicate) next.push(file); } return next; }); toast.success( validFiles.length === 1 ? 'Audio file attached. Click Upload to send.' : `${validFiles.length} audio files attached. Click Upload to send.` ); }, []); const handleVoiceFileChange = (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); if (files.length === 0) return; setUploadTab('voice'); stageAudioFiles(files); if (voiceInputRef.current) voiceInputRef.current.value = ''; }; const handleVoiceUpload = useCallback(async () => { if (audioBlob && pendingAudioFiles.length === 0) { setIsUploadingVoice(true); try { const ok = await uploadSingleAudioAsset( audioBlob, 'recording.webm', voiceTitle.trim() || 'Voice Recording' ); if (ok) { setVoiceTitle(''); setAudioBlob(null); setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); } } finally { setIsUploadingVoice(false); } return; } if (pendingAudioFiles.length === 0) return; setIsUploadingVoice(true); let successCount = 0; let failCount = 0; try { for (const file of pendingAudioFiles) { const displayName = voiceTitle.trim() || file.name.replace(/\.[^/.]+$/, ''); const ok = await uploadSingleAudioAsset(file, file.name, displayName); if (ok) successCount += 1; else failCount += 1; } if (successCount > 0) { setVoiceTitle(''); setPendingAudioFiles([]); if (voiceInputRef.current) voiceInputRef.current.value = ''; } if (successCount > 0 && failCount === 0) { toast.success( successCount === 1 ? 'Audio uploaded' : `${successCount} audio files uploaded` ); } else if (successCount > 0 && failCount > 0) { toast.warning(`${successCount} uploaded, ${failCount} failed`); } } finally { setIsUploadingVoice(false); } }, [audioBlob, pendingAudioFiles, uploadSingleAudioAsset, voiceTitle]); const handleDragEnter = useCallback( (e: React.DragEvent) => { e.preventDefault(); if (!canUploadAssets) return; dragCounterRef.current += 1; if (e.dataTransfer.types.includes('Files')) setIsDragOver(true); }, [canUploadAssets] ); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current -= 1; if (dragCounterRef.current === 0) setIsDragOver(false); }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); }, []); const handleDrop = useCallback( async (e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current = 0; setIsDragOver(false); if (!canUploadAssets) return; const files = Array.from(e.dataTransfer.files); if (files.length === 0) return; const imageFiles: File[] = []; const videoFiles: File[] = []; const audioFiles: File[] = []; let unsupportedCount = 0; for (const file of files) { if (file.type.startsWith('image/')) { imageFiles.push(file); } else if (file.type.startsWith('video/')) { videoFiles.push(file); } else if (file.type.startsWith('audio/')) { audioFiles.push(file); } else { unsupportedCount += 1; toast.error(`${file.name}: Unsupported file type`); } } if (imageFiles.length > 0) { setUploadTab('image'); await stageImageFiles(imageFiles); } if (audioFiles.length > 0) { setUploadTab('voice'); stageAudioFiles(audioFiles); } if (videoFiles.length > 0) { setUploadTab('bunny'); await handleVideoBatchUpload(videoFiles); } if ( imageFiles.length === 0 && videoFiles.length === 0 && audioFiles.length === 0 && unsupportedCount === 0 ) { toast.error('Drop an image, video, or audio file.'); } }, [canUploadAssets, handleVideoBatchUpload, stageAudioFiles, stageImageFiles] ); const renderAssetPreview = (asset: VideoAsset) => { if (asset.kind === 'AUDIO') { return (
Voice Recording
); } if (asset.kind === 'IMAGE') { const imageSrc = asset.thumbnailUrl || asset.sourceUrl; return (
{imageSrc ? ( // eslint-disable-next-line @next/next/no-img-element {asset.displayName} ) : ( )}
); } if (asset.provider === 'YOUTUBE' && asset.providerVideoId) { return (
{/* eslint-disable-next-line @next/next/no-img-element */} {asset.displayName}
); } if (asset.provider === 'R2_VIDEO') { const thumbnailSrc = asset.thumbnailUrl; return (
{thumbnailSrc ? ( // eslint-disable-next-line @next/next/no-img-element {asset.displayName} ) : ( )}
); } const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0; const isProcessing = !!bunnyProcessingByAssetId[asset.id]; const isReadyToPlay = !!bunnyReadyByAssetId[asset.id]; const hasThumbnailLoadError = !!bunnyThumbnailLoadErrorByAssetId[asset.id]; const thumbnailSrc = asset.thumbnailUrl ? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}` : null; const showThumbnailImage = !!thumbnailSrc && !hasThumbnailLoadError; return (
{showThumbnailImage ? ( // eslint-disable-next-line @next/next/no-img-element handleBunnyThumbnailLoad(asset.id)} onError={() => handleBunnyThumbnailError(asset.id)} /> ) : isReadyToPlay ? (
Ready to play
) : ( )} {isProcessing && !isReadyToPlay && (
Processing...
)}
); }; const handleOpenAsset = (asset: VideoAsset) => { if (asset.kind === 'IMAGE') { if (!asset.sourceUrl) { toast.error('Preview is unavailable for this asset'); return; } setPreviewImage(asset.sourceUrl); setPreviewImageTitle(asset.displayName); return; } if (asset.kind === 'AUDIO') { setSelectedAsset(asset); return; } if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) { setBunnyProcessingByAssetId((prev) => prev[asset.id] ? prev : { ...prev, [asset.id]: true } ); } setSelectedAsset(asset); }; const selectedBunnyAssetId = selectedAsset?.provider === 'BUNNY' ? selectedAsset.id : null; const isSelectedBunnyProcessing = selectedBunnyAssetId ? !!bunnyProcessingByAssetId[selectedBunnyAssetId] && !bunnyReadyByAssetId[selectedBunnyAssetId] : false; return (
Assets {assets.length}
{canUploadAssets ? (
{isDragOver && (
Drop files to upload Multiple images, videos, or audio supported
)} setUploadTab(value as 'image' | 'youtube' | 'bunny' | 'voice') } > Image YouTube Video Voice {uploadTab === 'image' && (
setImageTitle(event.target.value)} />

If set, this name will be used in @asset mentions.

Tip: paste an image with Ctrl/Cmd+V, or drop multiple files onto this panel.

{pendingImageFiles.length > 0 ? (
{pendingImageFiles.map((file, index) => (
Attached: {file.name}
))}
) : null}
)} {uploadTab === 'youtube' && (
setYoutubeUrl(event.target.value)} /> setYoutubeTitle(event.target.value)} />
)} {uploadTab === 'bunny' && (
setBunnyTitle(event.target.value)} />

If set, this name will be used in @asset mentions.

Drop multiple video files onto this panel to upload them in sequence.

{isUploadingBunny && (
{bunnyUploadLabel ? (

{bunnyUploadLabel}

) : null}
)}
)} {uploadTab === 'voice' && (
setVoiceTitle(e.target.value)} disabled={isRecording || isUploadingVoice} /> {pendingAudioFiles.length > 0 ? (
{pendingAudioFiles.map((file, index) => (
Attached: {file.name}
))}
) : isRecording ? (
Recording {String(Math.floor(recordingTime / 60)).padStart(2, '0')}: {String(recordingTime % 60).padStart(2, '0')}
) : audioBlob ? (
{playingVoiceId === 'recording-preview' ? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}` : formatTime(recordingTime)} {playingVoiceId === 'recording-preview' && ( )}
) : (
)}

Or drag multiple audio files anywhere onto this panel.

)}
) : (
You do not have permission to upload assets.
)} void downloadAsset(asset, preference)} onDeleteAsset={(assetId) => void deleteAsset(assetId)} onLoadMoreAssets={() => void loadMoreAssets()} renderAssetPreview={renderAssetPreview} /> { setPreviewImage(null); setPreviewImageTitle(null); }} /> { if (!open) { stopVoice(); setSelectedAsset(null); } }} > {selectedAsset?.displayName || 'Voice Recording'} {selectedAsset?.sourceUrl ? (
{playingVoiceId === selectedAsset?.id ? formatTime(voiceCurrentTime) : '00:00'} {playingVoiceId === selectedAsset?.id && ( )}
) : (

Audio preview unavailable.

)}
!open && setSelectedAsset(null)} > setSelectedAsset(null)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === 'Escape') { event.preventDefault(); setSelectedAsset(null); } }} > {selectedAsset?.displayName || 'Video Preview'}
e.stopPropagation()} >

{selectedAsset?.displayName || 'Video Preview'}

{selectedAsset?.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? ( ) : selectedAsset?.provider === 'R2_VIDEO' && canDownloadAssets ? ( ) : null} {selectedAsset?.provider === 'BUNNY' && canDownloadAssets ? ( void downloadAsset(selectedAsset, 'original')}> Original void downloadAsset(selectedAsset, 'compressed')} > Compressed ) : null}
{selectedAsset ? ( selectedAsset.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (