'use client'; import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import Hls, { type Level } from 'hls.js'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { ArrowLeft, Play, Pause, Volume2, VolumeX, SkipBack, SkipForward, Gauge, MessageSquare, MessageSquareOff, Mic, Send, Clock, CheckCircle2, Circle, ChevronDown, MoreVertical, Plus, Loader2, Link as LinkIcon, AlertCircle, GitCompareArrows, Reply, Pencil, Trash2, X, ArrowUpRight, Tag, User, Maximize, Minimize, Image as ImageIcon, Download, FileText, Share2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Textarea } from '@/components/ui/textarea'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers'; import { AnnotationCanvas, type AnnotationStroke, type AnnotationCanvasHandle } from '@/components/annotation-canvas'; import { Linkify } from '@/components/linkify'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import * as tus from 'tus-js-client'; import { UploadCloud, FileVideo } from 'lucide-react'; interface Version { id: string; versionNumber: number; versionLabel: string | null; providerId: string; videoId: string; originalUrl: string; title: string | null; thumbnailUrl: string | null; duration: number | null; isActive: boolean; _count: { comments: number }; } interface CommentTag { id: string; name: string; color: string; } interface PlayerAdapter { playVideo: () => void; pauseVideo: () => void; seekTo: (time: number, allowSeekAhead?: boolean) => void; mute: () => void; unMute: () => void; isMuted: () => boolean; getCurrentTime: () => number; getDuration: () => number; getPlayerState: () => number; setPlaybackRate: (rate: number) => void; destroy: () => void; off?: (event: string) => void; } interface Comment { id: string; content: string | null; timestamp: number; voiceUrl: string | null; voiceDuration: number | null; imageUrl: string | null; annotationData: string | null; isResolved: boolean; createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; canEdit?: boolean; canDelete?: boolean; tag: CommentTag | null; replies: { id: string; content: string | null; voiceUrl: string | null; voiceDuration: number | null; imageUrl: string | null; annotationData: string | null; createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; canEdit?: boolean; canDelete?: boolean; tag: CommentTag | null; }[]; } interface VideoData { id: string; title: string; description: string | null; projectId: string; project: { name: string; ownerId: string; members?: { role: string }[]; visibility?: string; }; versions: (Version & { comments: Comment[] })[]; isAuthenticated: boolean; currentUserId: string | null; currentUserName: string | null; canComment?: boolean; canDownload?: boolean; canManageTags?: boolean; canResolveComments?: boolean; } function formatTime(seconds: number): string { const totalSeconds = Math.floor(seconds); const hrs = Math.floor(totalSeconds / 3600); const mins = Math.floor((totalSeconds % 3600) / 60); const secs = totalSeconds % 60; if (hrs > 0) { return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } return `${mins}:${secs.toString().padStart(2, '0')}`; } 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}`; } function sanitizeDownloadFileName(value: string): string { return value .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') .replace(/\s+/g, ' ') .trim(); } const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net'; const DIRECT_DOWNLOAD_ALLOWED_HOSTS = [ BUNNY_PULL_ZONE_HOSTNAME, ...(process.env.NEXT_PUBLIC_BUNNY_CDN_URL ? (() => { try { return [new URL(process.env.NEXT_PUBLIC_BUNNY_CDN_URL).hostname]; } catch { return [process.env.NEXT_PUBLIC_BUNNY_CDN_URL.replace(/^https?:\/\//, '').replace(/\/+$/, '')]; } })() : []), ...(process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '').split(','), ] .map((host) => host.trim().toLowerCase()) .filter(Boolean); function getSafeDirectDownloadUrl(rawUrl: string): string | null { try { const parsed = new URL(rawUrl); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return null; } if (DIRECT_DOWNLOAD_ALLOWED_HOSTS.length === 0) { return null; } const normalizedHost = parsed.hostname.toLowerCase(); if (!DIRECT_DOWNLOAD_ALLOWED_HOSTS.includes(normalizedHost)) { return null; } return parsed.toString(); } catch { return null; } } interface BunnyQualityOption { level: number; label: string; } type BunnyPlaybackState = 'none' | 'processing' | 'error'; type BunnyDownloadPreference = 'original' | 'compressed'; type DownloadTarget = BunnyDownloadPreference | 'direct'; export type VideoPageMode = 'dashboard' | 'watch'; interface VideoPageContentProps { mode: VideoPageMode; videoId: string; projectId?: string; } export function VideoPageContent({ mode, videoId, projectId: propProjectId }: VideoPageContentProps) { const iframeRef = useRef(null); const videoRef = useRef(null); const bunnyViewportRef = useRef(null); const hlsRef = useRef(null); const playerRef = useRef(null); const timelineRef = useRef(null); const videoContainerRef = useRef(null); const pathname = usePathname(); const [video, setVideo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [activeVersionId, setActiveVersionId] = useState(null); const [isReady, setIsReady] = useState(false); const [bunnyPlaybackState, setBunnyPlaybackState] = useState('none'); const [currentTime, setCurrentTime] = useState(0); const [videoDuration, setVideoDuration] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [isMuted, setIsMuted] = useState(false); const [isDragging, setIsDragging] = useState(false); const isDraggingRef = useRef(false); const [playbackSpeed, setPlaybackSpeed] = useState(1); const [qualityOptions, setQualityOptions] = useState([]); const [selectedQualityLevel, setSelectedQualityLevel] = useState(-1); const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false); const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState(0); const [cursorIdle, setCursorIdle] = useState(false); const cursorIdleTimerRef = useRef | null>(null); const lastPathnameRef = useRef(pathname); const [commentText, setCommentText] = useState(''); const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [isRecording, setIsRecording] = useState(false); const [recordingTime, setRecordingTime] = useState(0); const [audioBlob, setAudioBlob] = useState(null); const [isUploadingAudio, setIsUploadingAudio] = useState(false); const [imageBlob, setImageBlob] = useState(null); const [isUploadingImage, setIsUploadingImage] = useState(false); const imageInputRef = useRef(null); const [playingVoiceId, setPlayingVoiceId] = useState(null); const [voiceProgress, setVoiceProgress] = useState(0); const [voiceCurrentTime, setVoiceCurrentTime] = useState(0); const [voicePlaybackRate, setVoicePlaybackRate] = useState(1); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const recordingTimerRef = useRef | null>(null); const audioPlayerRef = useRef(null); const voiceRafRef = useRef(null); const voiceKnownDurationRef = useRef(0); const [selectedTimestamp, setSelectedTimestamp] = useState(null); const [showResolved, setShowResolved] = useState(false); const [isExportingCsv, setIsExportingCsv] = useState(false); const [isExportingPdf, setIsExportingPdf] = useState(false); const [activeDownloadTarget, setActiveDownloadTarget] = useState(null); // Watch progress state const [savedProgress, setSavedProgress] = useState(null); const [showResumePrompt, setShowResumePrompt] = useState(false); const progressSaveTimerRef = useRef | null>(null); const lastSavedProgressRef = useRef(0); const bunnyRetryTimerRef = useRef | null>(null); // Fullscreen state const [isFullscreenMode, setIsFullscreenMode] = useState(false); const [showComments, setShowComments] = useState(true); const [isMobileCommentsOpen, setIsMobileCommentsOpen] = useState(false); // YouTube API loading state const [isApiLoaded, setIsApiLoaded] = useState(false); const [progressFetchKey, setProgressFetchKey] = useState(0); const [replyingTo, setReplyingTo] = useState(null); const [replyText, setReplyText] = useState(''); const [isSubmittingReply, setIsSubmittingReply] = useState(false); const [isReplyRecording, setIsReplyRecording] = useState(false); const [replyRecordingTime, setReplyRecordingTime] = useState(0); const [replyAudioBlob, setReplyAudioBlob] = useState(null); const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false); const [replyImageBlob, setReplyImageBlob] = useState(null); const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false); const replyImageInputRef = useRef(null); const replyMediaRecorderRef = useRef(null); const replyAudioChunksRef = useRef([]); const replyRecordingTimerRef = useRef | null>(null); const [editingCommentId, setEditingCommentId] = useState(null); const [editText, setEditText] = useState(''); const [editTagId, setEditTagId] = useState(null); const [editAnnotationData, setEditAnnotationData] = useState(undefined); const [isEditingAnnotation, setIsEditingAnnotation] = useState(false); const editAnnotationCanvasRef = useRef(null); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [, setDeletingCommentId] = useState(null); const isMutatingRef = useRef(false); const [previewImage, setPreviewImage] = useState(null); // Annotation state const [isAnnotating, setIsAnnotating] = useState(false); const [annotationStrokes, setAnnotationStrokes] = useState(null); const [viewingAnnotation, setViewingAnnotation] = useState(null); const annotationCanvasRef = useRef(null); const [guestName, setGuestName] = useState(''); const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard'); // Compare dialog state const [showCompareDialog, setShowCompareDialog] = useState(false); const [selectedCompareVersions, setSelectedCompareVersions] = useState>(new Set()); const router = useRouter(); useEffect(() => { isDraggingRef.current = isDragging; }, [isDragging]); useEffect(() => { const viewportEl = bunnyViewportRef.current; if (!viewportEl || typeof ResizeObserver === 'undefined') return; const updateFrameWidth = () => { const viewportWidth = viewportEl.clientWidth; const viewportHeight = viewportEl.clientHeight; if (viewportWidth <= 0 || viewportHeight <= 0) return; setBunnyPortraitFrameWidth(Math.min(viewportWidth, viewportHeight * (9 / 16))); }; updateFrameWidth(); const observer = new ResizeObserver(updateFrameWidth); observer.observe(viewportEl); return () => observer.disconnect(); }, [activeVersionId]); useEffect(() => { const saved = localStorage.getItem('openframe_guest_name'); if (saved) { setGuestName(saved); if (mode === 'watch') setGuestNameConfirmed(true); } }, [mode]); const isGuest = video ? !video.isAuthenticated : false; const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed; const normalizedGuestName = guestName.trim(); const [showVersionDialog, setShowVersionDialog] = useState(false); const [newVersionUrl, setNewVersionUrl] = useState(''); const [newVersionLabel, setNewVersionLabel] = useState(''); const [newVersionSource, setNewVersionSource] = useState(null); const [newVersionUrlError, setNewVersionUrlError] = useState(''); const [isCreatingVersion, setIsCreatingVersion] = useState(false); const [newVersionMode, setNewVersionMode] = useState<'url' | 'file'>('url'); const [newVersionFile, setNewVersionFile] = useState(null); const [newVersionUploadProgress, setNewVersionUploadProgress] = useState(0); const [newVersionUploadStatus, setNewVersionUploadStatus] = useState(''); const [availableTags, setAvailableTags] = useState([]); const [selectedTagId, setSelectedTagId] = useState(null); const projectId = propProjectId || video?.projectId; // Cursor idle detection: hide overlay when cursor idle for 3s while playing // Memoize version selection handler to prevent recreating on each render const handleVersionSelect = useCallback((versionId: string) => { setActiveVersionId(versionId); }, []); // Memoize toggle show resolved handler const handleToggleShowResolved = useCallback(() => { setShowResolved(prev => !prev); }, []); const handleExportComments = useCallback( async (format: 'csv' | 'pdf') => { if (!activeVersionId) return; if (format === 'csv') { setIsExportingCsv(true); } else { setIsExportingPdf(true); } try { const response = await fetch( `/api/versions/${activeVersionId}/comments/export?format=${format}&includeResolved=${showResolved}` ); if (!response.ok) { let message = 'Failed to export comments'; try { const data = await response.json(); if (typeof data?.error === 'string') { message = data.error; } } catch { // Keep fallback message when response is not JSON. } throw new Error(message); } const blob = await response.blob(); const disposition = response.headers.get('content-disposition'); const fallbackName = `comments.${format}`; const matched = disposition?.match(/filename="?([^"]+)"?/i); const filename = matched?.[1] || fallbackName; const downloadUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = downloadUrl; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(downloadUrl); toast.success(`Comments exported as ${format.toUpperCase()}`); } catch (error) { console.error('Failed to export comments:', error); toast.error(error instanceof Error ? error.message : 'Failed to export comments'); } finally { if (format === 'csv') { setIsExportingCsv(false); } else { setIsExportingPdf(false); } } }, [activeVersionId, showResolved] ); const handleVideoMouseMove = useCallback(() => { setCursorIdle(false); if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); // In fullscreen mode: hide header AND controls when cursor idle for 1s while playing // Non-fullscreen: hide only the play overlay (existing behavior) const shouldHideControls = isFullscreenMode; if (isPlaying || shouldHideControls) { cursorIdleTimerRef.current = setTimeout(() => { setCursorIdle(true); }, 1000); } }, [isFullscreenMode, isPlaying]); const handleVideoMouseLeave = useCallback(() => { if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); setCursorIdle(false); }, []); useEffect(() => { return () => { if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); }; }, []); // Determine current user info for permission checks and comment display const currentUserId = video?.currentUserId || null; const currentUserName = video?.currentUserName || null; const canResolveComments = !!video?.canResolveComments; const apiBasePath = mode === 'dashboard' ? `/api/projects/${propProjectId}/videos/${videoId}` : `/api/watch/${videoId}?includeComments=true`; useEffect(() => { async function fetchVideo() { try { const res = await fetch(apiBasePath, { cache: 'no-store' }); if (!res.ok) { const errorText = mode === 'dashboard' ? await res.text() : ''; setError(mode === 'dashboard' ? `Failed to load video: ${res.status} ${errorText}` : 'Video not found or access denied' ); setLoading(false); return; } const response = await res.json(); const data = response.data; setVideo(data); const active = data.versions?.find((v: Version) => v.isActive) || data.versions?.[0]; if (active) setActiveVersionId(active.id); } catch (err) { console.error('Error fetching video:', err); setError('Failed to load video'); } finally { setLoading(false); } } fetchVideo(); }, [apiBasePath, mode]); // Memoize active version lookup to avoid recalculating on every render const activeVersion = useMemo(() => { return video?.versions?.find((v) => v.id === activeVersionId) || video?.versions?.find((v) => v.isActive) || video?.versions?.[0]; }, [video?.versions, activeVersionId]); const activeProviderId = activeVersion?.providerId; const activeVersionDuration = activeVersion?.duration; const isDownloadingVideo = activeDownloadTarget !== null; const isVideoDownloadAvailable = useMemo(() => { if (!activeVersion || !video?.canDownload) return false; if (activeVersion.providerId === 'bunny') return true; if (activeVersion.providerId !== 'direct') return false; return !!getSafeDirectDownloadUrl(activeVersion.originalUrl); }, [activeVersion, video?.canDownload]); const handleDownloadVideo = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => { if (!activeVersion || !video || isDownloadingVideo) return; if (!video.canDownload) { toast.error('Download is disabled for this shared link'); return; } if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') { toast.error('This video source does not support direct download'); return; } const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct'; setActiveDownloadTarget(target); try { let downloadUrl: string | null = null; if (activeVersion.providerId === 'bunny') { const prepareRes = await fetch(`/api/versions/${activeVersion.id}/download?source=${preference}&prepare=1`, { cache: 'no-store', }); if (!prepareRes.ok) { const prepareBody = await prepareRes.json().catch(() => null); const fallbackError = preference === 'original' ? 'Original file is not available for this video' : 'Compressed file is not available for this video'; const errorMessage = typeof prepareBody?.error === 'string' ? prepareBody.error : fallbackError; throw new Error(errorMessage); } downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`; } else { downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl); if (!downloadUrl) { throw new Error('Direct download URL is not allowed'); } } if (!downloadUrl) { throw new Error('Missing download URL'); } const versionLabel = activeVersion.versionLabel?.trim() || `v${activeVersion.versionNumber}`; const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video'; const a = document.createElement('a'); a.href = downloadUrl; if (activeVersion.providerId === 'direct') { a.download = `${baseName}.mp4`; } document.body.appendChild(a); a.click(); a.remove(); } catch (error) { console.error('Failed to start video download:', error); if (error instanceof Error && error.message === 'Direct download URL is not allowed') { toast.error('This direct download host is not allowed'); } else if (error instanceof Error && error.message) { toast.error(error.message); } else { toast.error('Failed to start download'); } } finally { setActiveDownloadTarget(null); } }, [activeVersion, isDownloadingVideo, video]); const getGuestUploadToken = useCallback(async (intent: 'audio' | 'image') => { if (!isGuest) return null; const response = await fetch(`/api/watch/${videoId}/upload-token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ intent }), }); const payload = (await response.json().catch(() => null)) as | { data?: { token?: string }; error?: string } | null; const token = payload?.data?.token; if (!response.ok || !token) { throw new Error(payload?.error || 'Failed to prepare upload'); } return token; }, [isGuest, videoId]); // Memoize comments array const comments = useMemo(() => { return activeVersion?.comments || []; }, [activeVersion]); // Memoize filtered comments to avoid filtering on every render const filteredComments = useMemo(() => { return comments.filter((c) => showResolved || !c.isResolved); }, [comments, showResolved]); // Memoize sorted comments to avoid sorting on every render const sortedComments = useMemo(() => { return [...filteredComments].sort((a, b) => a.timestamp - b.timestamp); }, [filteredComments]); // Memoize duration computation const duration = useMemo(() => { return videoDuration || activeVersion?.duration || 0; }, [videoDuration, activeVersion?.duration]); // Memoize embed URL calculation to avoid recalculating on every render const embedUrl = useMemo(() => { if (!activeVersion) return ''; if (activeVersion.providerId === 'youtube') { const base = `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`; if (typeof window === 'undefined') return base; const origin = window.location.origin; return `${base}&origin=${encodeURIComponent(origin)}`; } if (activeVersion.providerId === 'bunny') { return `https://${BUNNY_PULL_ZONE_HOSTNAME}/${activeVersion.videoId}/playlist.m3u8`; } try { const url = new URL(activeVersion.originalUrl); if (url.protocol !== 'http:' && url.protocol !== 'https:') { return ''; } return activeVersion.originalUrl; } catch { return ''; } }, [activeVersion]); const selectedQualityLabel = useMemo(() => { if (selectedQualityLevel === -1) return 'Auto'; return qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto'; }, [qualityOptions, selectedQualityLevel]); useEffect(() => { if (!projectId) return; async function fetchTags() { try { const query = videoId ? `?videoId=${encodeURIComponent(videoId)}` : ''; const res = await fetch(`/api/projects/${projectId}/tags${query}`); if (res.ok) { const data = await res.json(); const tags = data.data || []; setAvailableTags(tags); if (tags.length > 0 && !selectedTagId) { setSelectedTagId(tags[0].id); } } } catch { } } fetchTags(); }, [projectId, selectedTagId, videoId]); // Load YouTube API immediately on component mount (async, non-blocking) useEffect(() => { // Already loaded if (isApiLoaded) return; // Already in progress if (window.YT) { setIsApiLoaded(true); return; } const tag = document.createElement('script'); tag.src = 'https://www.youtube.com/iframe_api'; const firstScriptTag = document.getElementsByTagName('script')[0]; firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag); window.onYouTubeIframeAPIReady = () => { setIsApiLoaded(true); }; }, [isApiLoaded]); useEffect(() => { if (!canInitializePlayer) return; if (!activeProviderId) return; const isYoutube = activeProviderId === 'youtube'; const isBunny = activeProviderId === 'bunny'; if (isYoutube && !isApiLoaded) return; if (!isYoutube && !isBunny) return; setIsReady(false); setBunnyPlaybackState('none'); setCurrentTime(0); setVideoDuration(0); setIsPlaying(false); setIsMuted(false); setPlaybackSpeed(1); setQualityOptions([]); setSelectedQualityLevel(-1); setIsBunnyPortraitSource(false); if (playerRef.current) { try { playerRef.current.destroy(); } catch { /* ignore */ } playerRef.current = null; } if (hlsRef.current) { try { hlsRef.current.destroy(); } catch { /* ignore */ } hlsRef.current = null; } if (bunnyRetryTimerRef.current) { clearTimeout(bunnyRetryTimerRef.current); bunnyRetryTimerRef.current = null; } const initPlayer = () => { if (isYoutube) { if (!iframeRef.current) return; playerRef.current = new YT.Player(iframeRef.current, { events: { onReady: (event: YT.PlayerEvent) => { setIsReady(true); const dur = event.target.getDuration(); if (dur > 0) setVideoDuration(dur); }, onStateChange: (event: YT.OnStateChangeEvent) => { setIsPlaying(event.data === YT.PlayerState.PLAYING); if (event.data === YT.PlayerState.PAUSED) { const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0; const playerDuration = playerRef.current?.getDuration?.() || 0; if (video?.isAuthenticated && playerCurrentTime > 0 && activeVersionId) { fetch(`/api/watch/${videoId}/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ progress: playerCurrentTime, duration: playerDuration, versionId: activeVersionId, }), }).catch((err) => console.error('Error saving watch progress on pause:', err)); } } if (event.data === YT.PlayerState.PLAYING) { const dur = event.target.getDuration(); if (dur > 0) setVideoDuration(dur); } }, }, }); } else if (isBunny) { const videoEl = videoRef.current; if (!videoEl) return; let cachedDuration = 0; let destroyed = false; let retryAttempt = 0; let usingHlsJs = false; let hlsInstance: Hls | null = null; const clearRetryTimer = () => { if (bunnyRetryTimerRef.current) { clearTimeout(bunnyRetryTimerRef.current); bunnyRetryTimerRef.current = null; } }; const scheduleRetry = (retryFn: () => void) => { clearRetryTimer(); bunnyRetryTimerRef.current = setTimeout(() => { if (!destroyed) { retryFn(); } }, 3000); }; const getRetryUrl = () => { retryAttempt += 1; const separator = embedUrl.includes('?') ? '&' : '?'; return `${embedUrl}${separator}retry=${Date.now()}-${retryAttempt}`; }; const retryNativeLoad = () => { videoEl.src = getRetryUrl(); videoEl.load(); }; const retryHlsLoad = () => { if (destroyed || !hlsInstance) return; const retryUrl = getRetryUrl(); try { hlsInstance.stopLoad(); } catch { // ignore stop-load failures and continue with a fresh loadSource } hlsInstance.loadSource(retryUrl); hlsInstance.startLoad(-1); }; const syncDuration = () => { if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) { cachedDuration = videoEl.duration; setVideoDuration(videoEl.duration); } }; const saveProgress = () => { const current = videoEl.currentTime || 0; const duration = Number.isFinite(videoEl.duration) && videoEl.duration > 0 ? videoEl.duration : cachedDuration; if (video?.isAuthenticated && current > 0 && activeVersionId) { fetch(`/api/watch/${videoId}/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ progress: current, duration, versionId: activeVersionId, }), }).catch((err) => console.error('Error saving watch progress on pause:', err)); } }; const onLoadedMetadata = () => { if (destroyed) return; clearRetryTimer(); setBunnyPlaybackState('none'); if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) { setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth); } setIsReady(true); syncDuration(); }; const onPlay = () => { setIsPlaying(true); setBunnyPlaybackState('none'); syncDuration(); }; const onPause = () => { setIsPlaying(false); saveProgress(); }; const onEnded = () => { setIsPlaying(false); saveProgress(); }; const onTimeUpdate = () => { if (!isDraggingRef.current) { setCurrentTime(videoEl.currentTime || 0); } if (Number.isFinite(videoEl.duration) && videoEl.duration > 0 && videoEl.duration !== cachedDuration) { cachedDuration = videoEl.duration; setVideoDuration(videoEl.duration); } }; const onVideoError = () => { if (destroyed) return; if (usingHlsJs) return; if (videoEl.readyState >= HTMLMediaElement.HAVE_METADATA) { setBunnyPlaybackState('error'); return; } setIsReady(false); setBunnyPlaybackState('processing'); scheduleRetry(retryNativeLoad); }; videoEl.addEventListener('loadedmetadata', onLoadedMetadata); videoEl.addEventListener('play', onPlay); videoEl.addEventListener('pause', onPause); videoEl.addEventListener('ended', onEnded); videoEl.addEventListener('timeupdate', onTimeUpdate); videoEl.addEventListener('error', onVideoError); const configureHlsLevels = (levels: Level[]) => { setQualityOptions(levels.map((level, index) => ({ level: index, label: formatBunnyQualityLabel(level, index), }))); setSelectedQualityLevel(-1); }; if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { videoEl.src = embedUrl; videoEl.load(); } else if (Hls.isSupported()) { usingHlsJs = true; const hls = new Hls(); hlsInstance = hls; hlsRef.current = hls; hls.attachMedia(videoEl); hls.on(Hls.Events.MEDIA_ATTACHED, () => { if (!destroyed) { hls.loadSource(embedUrl); } }); hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => { if (destroyed) return; clearRetryTimer(); setBunnyPlaybackState('none'); configureHlsLevels(data.levels); setIsReady(true); syncDuration(); }); 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) { setIsReady(false); setBunnyPlaybackState('processing'); scheduleRetry(retryHlsLoad); return; } if (data.fatal) { setBunnyPlaybackState('error'); console.error('Fatal HLS error:', data); } }); } else { setBunnyPlaybackState('error'); console.error('HLS is not supported in this browser.'); } playerRef.current = { playVideo: () => { videoEl.play().catch((err) => console.error('Error playing Bunny video:', err)); }, pauseVideo: () => videoEl.pause(), seekTo: (time: number) => { videoEl.currentTime = time; }, mute: () => { videoEl.muted = true; }, unMute: () => { videoEl.muted = false; }, isMuted: () => videoEl.muted, getCurrentTime: () => videoEl.currentTime || 0, getDuration: () => { if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration; return cachedDuration; }, getPlayerState: () => ( videoEl.paused ? (window.YT?.PlayerState?.PAUSED ?? 2) : (window.YT?.PlayerState?.PLAYING ?? 1) ), setPlaybackRate: (rate: number) => { videoEl.playbackRate = rate; }, destroy: () => { destroyed = true; clearRetryTimer(); videoEl.removeEventListener('loadedmetadata', onLoadedMetadata); videoEl.removeEventListener('play', onPlay); videoEl.removeEventListener('pause', onPause); videoEl.removeEventListener('ended', onEnded); videoEl.removeEventListener('timeupdate', onTimeUpdate); videoEl.removeEventListener('error', onVideoError); if (hlsRef.current) { try { hlsRef.current.destroy(); } catch { /* ignore */ } hlsRef.current = null; } videoEl.removeAttribute('src'); videoEl.load(); }, }; } }; const timeout = setTimeout(() => { if (isYoutube) { if (window.YT?.Player) { initPlayer(); } else { window.onYouTubeIframeAPIReady = initPlayer; } } else if (isBunny) { initPlayer(); } }, 100); return () => { clearTimeout(timeout); if (isYoutube) { window.onYouTubeIframeAPIReady = undefined; } if (playerRef.current) { try { playerRef.current.destroy(); } catch { /* ignore */ } playerRef.current = null; } if (hlsRef.current) { try { hlsRef.current.destroy(); } catch { /* ignore */ } hlsRef.current = null; } if (bunnyRetryTimerRef.current) { clearTimeout(bunnyRetryTimerRef.current); bunnyRetryTimerRef.current = null; } }; }, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId, canInitializePlayer]); // Save detected duration to DB if the version doesn't have one stored useEffect(() => { if (!videoDuration || !activeVersionId || !propProjectId) return; if (activeVersionDuration && activeVersionDuration > 0) return; const roundedDuration = Math.round(videoDuration); // Fire-and-forget PATCH to save duration fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions/${activeVersionId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ duration: roundedDuration }), }).catch(() => { /* ignore save errors */ }); // Also update local state so the version object has the duration setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, duration: roundedDuration } : v ), }; }); }, [videoDuration, activeVersionDuration, activeVersionId, propProjectId, videoId]); // Load watch progress when video is loaded (authenticated users only) const loadWatchProgress = useCallback(async (showPrompt = true) => { if (!video?.isAuthenticated || !activeVersionId) return; // Reset state setSavedProgress(null); setShowResumePrompt(false); try { // Use cache: 'no-store' to always fetch fresh data const res = await fetch(`/api/watch/${videoId}/progress`, { cache: 'no-store' }); if (res.ok) { const response = await res.json(); const progress = response.data?.progress || 0; const percentage = response.data?.percentage || 0; // Only show resume prompt if progress is between 5% and 95% if (showPrompt && percentage > 5 && percentage < 95) { setSavedProgress(progress); setShowResumePrompt(true); } } } catch (err) { console.error('Error loading watch progress:', err); } }, [video?.isAuthenticated, activeVersionId, videoId]); // Load progress on mount and when dependencies change useEffect(() => { loadWatchProgress(); }, [loadWatchProgress, progressFetchKey]); // Refetch progress when pathname changes (user navigates back to this page) useEffect(() => { if (lastPathnameRef.current !== pathname) { const previousPath = lastPathnameRef.current; lastPathnameRef.current = pathname; // If we navigated away and came back to this video page, refetch progress if (previousPath !== pathname) { setProgressFetchKey(k => k + 1); } } }, [pathname]); // Save watch progress periodically while playing (authenticated users only) useEffect(() => { if (!video?.isAuthenticated || !isReady || !activeVersionId) return; // Save progress every 5 seconds while playing progressSaveTimerRef.current = setInterval(() => { const save = (playerCurrentTime: number, playerDuration: number) => { if (playerCurrentTime > 0 && Math.abs(playerCurrentTime - lastSavedProgressRef.current) >= 2) { fetch(`/api/watch/${videoId}/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ progress: playerCurrentTime, duration: playerDuration || videoDuration, versionId: activeVersionId, }), }).catch((err) => console.error('Error saving watch progress:', err)); lastSavedProgressRef.current = playerCurrentTime; } }; if (playerRef.current?.getCurrentTime) { save(playerRef.current.getCurrentTime(), playerRef.current.getDuration?.() || videoDuration); } }, 5000); return () => { if (progressSaveTimerRef.current) { clearInterval(progressSaveTimerRef.current); } }; }, [video?.isAuthenticated, isReady, currentTime, videoDuration, activeVersionId, videoId]); const toggleFullscreen = useCallback(() => { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().then(() => { setIsFullscreenMode(true); setShowComments(false); }).catch((err) => { console.error('Fullscreen failed:', err); toast.error('Unable to enter fullscreen mode'); }); } else { document.exitFullscreen().then(() => { setIsFullscreenMode(false); setShowComments(true); }).catch((err) => { console.error('Exit fullscreen failed:', err); toast.error('Unable to exit fullscreen mode'); }); } }, []); useEffect(() => { const handleFullscreenChange = () => { const isCurrentlyFullscreen = !!document.fullscreenElement; setIsFullscreenMode(isCurrentlyFullscreen); if (isCurrentlyFullscreen) { setShowComments(false); } else { setShowComments(true); } }; document.addEventListener('fullscreenchange', handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); }, []); // Save progress when user leaves the page useEffect(() => { if (!video?.isAuthenticated) return; const saveProgressOnLeave = () => { // Get current time and duration directly from player instance const playerCurrentTime = playerRef.current?.getCurrentTime?.() || currentTime; const playerDuration = playerRef.current?.getDuration?.() || videoDuration; if (playerCurrentTime > 0 && navigator.sendBeacon) { // Use sendBeacon for reliable save on page unload const data = new Blob([JSON.stringify({ progress: playerCurrentTime, duration: playerDuration, versionId: activeVersionId, })], { type: 'application/json' }); navigator.sendBeacon(`/api/watch/${videoId}/progress`, data); } }; // Save when tab becomes hidden (user switches tabs, minimizes, etc.) const handleVisibilityChange = () => { // Get current time and duration directly from player instance const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0; const playerDuration = playerRef.current?.getDuration?.() || videoDuration; if (document.visibilityState === 'hidden' && playerCurrentTime > 0 && activeVersionId) { fetch(`/api/watch/${videoId}/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ progress: playerCurrentTime, duration: playerDuration, versionId: activeVersionId, }), }).catch((err) => console.error('Error saving watch progress on visibility change:', err)); } }; window.addEventListener('beforeunload', saveProgressOnLeave); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('beforeunload', saveProgressOnLeave); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId]); const handleResumeFromSaved = useCallback(() => { if (savedProgress !== null && playerRef.current) { if (playerRef.current.seekTo) { playerRef.current.seekTo(savedProgress, true); } setCurrentTime(savedProgress); setShowResumePrompt(false); setSavedProgress(null); } }, [savedProgress]); // Dismiss resume prompt const handleDismissResume = useCallback(() => { setShowResumePrompt(false); setSavedProgress(null); }, []); useEffect(() => { if (!isReady || !playerRef.current) return; const interval = setInterval(() => { if (!isDragging && playerRef.current) { if (playerRef.current.getCurrentTime) { setCurrentTime(playerRef.current.getCurrentTime()); } } }, 250); return () => clearInterval(interval); }, [isReady, isDragging, activeVersion?.providerId]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } const isBunnyBlocked = activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none'; const isPlaybackControlKey = [ 'Space', 'KeyK', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Comma', 'Period', 'KeyM', 'KeyJ', 'KeyL', ].includes(e.code); if (isBunnyBlocked && isPlaybackControlKey) { e.preventDefault(); return; } switch (e.code) { case 'Space': case 'KeyK': e.preventDefault(); if (playerRef.current) { if (isPlaying) { playerRef.current.pauseVideo(); } else { playerRef.current.playVideo(); } } break; case 'ArrowLeft': e.preventDefault(); if (playerRef.current) { const newTime = Math.max(0, currentTime - 5); if (playerRef.current.seekTo) { playerRef.current.seekTo(newTime, true); } setCurrentTime(newTime); } break; case 'ArrowRight': e.preventDefault(); if (playerRef.current) { const newTime = Math.min(duration, currentTime + 5); if (playerRef.current.seekTo) { playerRef.current.seekTo(newTime, true); } setCurrentTime(newTime); } break; case 'ArrowUp': e.preventDefault(); { const speeds = SPEED_OPTIONS; const currentIndex = speeds.indexOf(playbackSpeed); if (currentIndex < speeds.length - 1) { const newSpeed = speeds[currentIndex + 1]; setPlaybackSpeed(newSpeed); playerRef.current?.setPlaybackRate(newSpeed); } } break; case 'ArrowDown': e.preventDefault(); { const speeds = SPEED_OPTIONS; const currentIndex = speeds.indexOf(playbackSpeed); if (currentIndex > 0) { const newSpeed = speeds[currentIndex - 1]; setPlaybackSpeed(newSpeed); playerRef.current?.setPlaybackRate(newSpeed); } } break; case 'Comma': if (e.shiftKey) { e.preventDefault(); const speeds = SPEED_OPTIONS; const currentIndex = speeds.indexOf(playbackSpeed); if (currentIndex > 0) { const newSpeed = speeds[currentIndex - 1]; setPlaybackSpeed(newSpeed); playerRef.current?.setPlaybackRate(newSpeed); } } break; case 'Period': if (e.shiftKey) { e.preventDefault(); const speeds = SPEED_OPTIONS; const currentIndex = speeds.indexOf(playbackSpeed); if (currentIndex < speeds.length - 1) { const newSpeed = speeds[currentIndex + 1]; setPlaybackSpeed(newSpeed); playerRef.current?.setPlaybackRate(newSpeed); } } break; case 'KeyM': e.preventDefault(); if (playerRef.current) { if (isMuted) { playerRef.current.unMute(); } else { playerRef.current.mute(); } setIsMuted(!isMuted); } break; case 'KeyJ': e.preventDefault(); if (playerRef.current?.seekTo) { const newTime = Math.max(0, currentTime - 10); playerRef.current.seekTo(newTime, true); setCurrentTime(newTime); } break; case 'KeyL': e.preventDefault(); if (playerRef.current?.seekTo) { const newTime = Math.min(duration, currentTime + 10); playerRef.current.seekTo(newTime, true); setCurrentTime(newTime); } break; case 'KeyF': e.preventDefault(); toggleFullscreen(); break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, currentTime, duration, isMuted, playbackSpeed, toggleFullscreen]); const handlePlayPause = useCallback(() => { if (activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none') return; if (!playerRef.current) return; if (isPlaying) { playerRef.current.pauseVideo(); } else { playerRef.current.playVideo(); } }, [activeVersion?.providerId, bunnyPlaybackState, isPlaying]); const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => { setCurrentTime(timestamp); if (playerRef.current?.seekTo) { const playerState = playerRef.current.getPlayerState?.(); const ytPlayingState = window.YT?.PlayerState?.PLAYING ?? 1; const ytBufferingState = window.YT?.PlayerState?.BUFFERING ?? 3; const wasPlayingBeforeSeek = typeof playerState === 'number' ? playerState === ytPlayingState || playerState === ytBufferingState : isPlaying; playerRef.current.seekTo(timestamp, true); // Preserve playback state when seeking so timeline clicks do not force-pause. if (wasPlayingBeforeSeek) { playerRef.current.playVideo(); } else { playerRef.current.pauseVideo(); } } // Show annotation overlay if present if (annotation) { try { const strokes = JSON.parse(annotation) as AnnotationStroke[]; setViewingAnnotation(strokes); } catch { setViewingAnnotation(null); } } else { setViewingAnnotation(null); } }, [isPlaying]); const handleMuteToggle = useCallback(() => { if (!playerRef.current) return; if (isMuted) { playerRef.current.unMute(); } else { playerRef.current.mute(); } setIsMuted(!isMuted); }, [isMuted]); const handleSkip = useCallback( (seconds: number) => { const newTime = Math.max(0, Math.min(duration, currentTime + seconds)); handleSeekToTimestamp(newTime); }, [currentTime, duration, handleSeekToTimestamp] ); const handleSpeedChange = useCallback( (speed: number) => { setPlaybackSpeed(speed); playerRef.current?.setPlaybackRate(speed); }, [] ); const handleQualityChange = useCallback((level: number) => { const hls = hlsRef.current; if (!hls) return; if (level === -1) { hls.currentLevel = -1; hls.nextLevel = -1; setSelectedQualityLevel(-1); return; } hls.currentLevel = level; hls.nextLevel = level; setSelectedQualityLevel(level); }, []); const handleTimelineClick = useCallback( (e: React.MouseEvent) => { if (!timelineRef.current) return; const rect = timelineRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const percentage = Math.max(0, Math.min(1, x / rect.width)); const newTime = percentage * duration; handleSeekToTimestamp(newTime); }, [duration, handleSeekToTimestamp] ); const handleTimelineMouseDown = useCallback( (e: React.MouseEvent) => { setIsDragging(true); handleTimelineClick(e); }, [handleTimelineClick] ); const handleTimelineMouseMove = useCallback( (e: React.MouseEvent) => { if (!isDragging || !timelineRef.current) return; const rect = timelineRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const percentage = Math.max(0, Math.min(1, x / rect.width)); setCurrentTime(percentage * duration); }, [isDragging, duration] ); const handleTimelineMouseUp = useCallback(() => { if (isDragging) { handleSeekToTimestamp(currentTime); setIsDragging(false); } }, [isDragging, currentTime, handleSeekToTimestamp]); const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => { if (!voiceData && !imageBlob && !commentText.trim() && !annotationStrokes && !isAnnotating) return; if (!activeVersion) return; // Auto-capture strokes from canvas if still in draw mode let effectiveStrokes = annotationStrokes; if (isAnnotating && annotationCanvasRef.current) { const canvasStrokes = annotationCanvasRef.current.getStrokes(); if (canvasStrokes.length > 0) { effectiveStrokes = canvasStrokes; } } const tempId = `temp-${Date.now()}`; const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null; const optimisticComment: Comment = { id: tempId, content: (voiceData || imageBlob) ? commentText.trim() || null : commentText, timestamp: selectedTimestamp ?? currentTime, voiceUrl: voiceData?.url ?? null, voiceDuration: voiceData?.duration ?? null, imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null, annotationData: serializedAnnotation, isResolved: false, createdAt: new Date().toISOString(), author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, guestName: isGuest ? normalizedGuestName : null, canEdit: true, canDelete: true, tag: availableTags.find(t => t.id === selectedTagId) || null, replies: [], }; setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: [...v.comments, optimisticComment] } : v ), }; }); setCommentText(''); setSelectedTimestamp(null); setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null); setAudioBlob(null); setImageBlob(null); setAnnotationStrokes(null); setIsAnnotating(false); setViewingAnnotation(effectiveStrokes || null); setIsSubmittingComment(true); isMutatingRef.current = true; try { let imageData: { url: string } | undefined; if (imageBlob) { setIsUploadingImage(true); const imageFormData = new FormData(); imageFormData.append('image', imageBlob); imageFormData.append('videoId', videoId); const uploadToken = await getGuestUploadToken('image'); if (uploadToken) imageFormData.append('uploadToken', uploadToken); const imageRes = await fetch('/api/upload/image', { method: 'POST', body: imageFormData, }); if (!imageRes.ok) throw new Error('Failed to upload image'); const imageDataResponse = await imageRes.json(); imageData = { url: imageDataResponse.data.url }; } const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: (voiceData || imageBlob) ? commentText.trim() || null : commentText, timestamp: selectedTimestamp ?? currentTime, ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(imageData && { imageUrl: imageData.url }), ...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }), ...(selectedTagId && { tagId: selectedTagId }), ...(serializedAnnotation && { annotationData: serializedAnnotation }), }), }); if (res.ok) { const response = await res.json(); const newComment = response.data; setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.map(c => c.id === tempId ? { ...newComment, replies: newComment.replies || [] } : { ...c, replies: c.replies || [] }) } : v ), }; }); } else { setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.filter(c => c.id !== tempId) } : v ), }; }); toast.error('Failed to add comment'); } } catch { setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.filter(c => c.id !== tempId) } : v ), }; }); toast.error('Failed to add comment'); } finally { setIsSubmittingComment(false); setIsUploadingImage(false); isMutatingRef.current = false; } }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, normalizedGuestName, currentUserName, selectedTagId, availableTags, imageBlob, annotationStrokes, isAnnotating, videoId, getGuestUploadToken]); const handleImageSelect = useCallback((e: React.ChangeEvent, isReply: boolean = false) => { const file = e.target.files?.[0]; if (!file) return; if (!file.type.startsWith('image/')) { toast.error('Please select an image file'); return; } if (file.size > 10 * 1024 * 1024) { toast.error('Image must be less than 10MB'); return; } if (isReply) { setReplyImageBlob(file); } else { setImageBlob(file); } }, []); const handlePaste = useCallback((e: React.ClipboardEvent, isReply: boolean = false) => { const items = e.clipboardData?.items; if (!items) return; for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { const file = items[i].getAsFile(); if (file) { if (file.size > 10 * 1024 * 1024) { toast.error('Image must be less than 10MB'); return; } if (isReply) { setReplyImageBlob(file); } else { setImageBlob(file); } e.preventDefault(); break; } } } }, []); const startRecording = useCallback(async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm', }); audioChunksRef.current = []; mediaRecorderRef.current = mediaRecorder; mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) { audioChunksRef.current.push(e.data); } }; mediaRecorder.onstop = () => { const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); setAudioBlob(blob); stream.getTracks().forEach((track) => track.stop()); if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); recordingTimerRef.current = null; } }; mediaRecorder.start(100); setIsRecording(true); setRecordingTime(0); recordingTimerRef.current = setInterval(() => { setRecordingTime((prev) => prev + 0.1); }, 100); } catch (err) { console.error('Failed to start recording:', err); } }, []); const stopRecording = useCallback(() => { if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { mediaRecorderRef.current.stop(); } setIsRecording(false); }, []); const cancelRecording = useCallback(() => { if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { mediaRecorderRef.current.stop(); } setIsRecording(false); setAudioBlob(null); setRecordingTime(0); }, []); const submitVoiceComment = useCallback(async () => { if (!audioBlob || !activeVersion) return; setIsUploadingAudio(true); try { const formData = new FormData(); formData.append('audio', audioBlob, 'recording.webm'); formData.append('videoId', videoId); const uploadToken = await getGuestUploadToken('audio'); if (uploadToken) formData.append('uploadToken', uploadToken); const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData, }); if (!uploadRes.ok) { throw new Error('Failed to upload audio'); } const uploadData = await uploadRes.json(); const { url } = uploadData.data; await handleAddComment({ url, duration: recordingTime }); setAudioBlob(null); setRecordingTime(0); } catch (err) { console.error('Failed to submit voice comment:', err); } finally { setIsUploadingAudio(false); } }, [audioBlob, activeVersion, recordingTime, handleAddComment, videoId, getGuestUploadToken]); const stopVoiceTracking = useCallback(() => { if (voiceRafRef.current) { cancelAnimationFrame(voiceRafRef.current); voiceRafRef.current = null; } }, []); const startVoiceTracking = useCallback(() => { stopVoiceTracking(); const tick = () => { const audio = audioPlayerRef.current; if (audio) { const dur = isFinite(audio.duration) && audio.duration > 0 ? audio.duration : voiceKnownDurationRef.current; if (dur > 0) { setVoiceProgress((audio.currentTime / dur) * 100); setVoiceCurrentTime(audio.currentTime); } } voiceRafRef.current = requestAnimationFrame(tick); }; voiceRafRef.current = requestAnimationFrame(tick); }, [stopVoiceTracking]); const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => { if (playingVoiceId === commentId) { if (audioPlayerRef.current) { audioPlayerRef.current.pause(); audioPlayerRef.current = null; } stopVoiceTracking(); setPlayingVoiceId(null); setVoiceProgress(0); setVoiceCurrentTime(0); return; } if (audioPlayerRef.current) { audioPlayerRef.current.pause(); } stopVoiceTracking(); voiceKnownDurationRef.current = knownDuration || 0; const audio = new Audio(voiceUrl); audio.playbackRate = voicePlaybackRate; audioPlayerRef.current = audio; setPlayingVoiceId(commentId); setVoiceProgress(0); setVoiceCurrentTime(0); audio.onplay = () => { startVoiceTracking(); }; audio.onended = () => { stopVoiceTracking(); setPlayingVoiceId(null); setVoiceProgress(0); setVoiceCurrentTime(0); audioPlayerRef.current = null; }; audio.onerror = () => { stopVoiceTracking(); setPlayingVoiceId(null); setVoiceProgress(0); setVoiceCurrentTime(0); audioPlayerRef.current = null; }; audio.play(); }, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]); const toggleVoiceSpeed = useCallback(() => { setVoicePlaybackRate((prev) => { const next = prev === 1 ? 2 : 1; if (audioPlayerRef.current) { audioPlayerRef.current.playbackRate = next; } return next; }); }, []); useEffect(() => { return () => { if (audioPlayerRef.current) { audioPlayerRef.current.pause(); audioPlayerRef.current = null; } stopVoiceTracking(); if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); } }; }, [stopVoiceTracking]); const submitCommentWithMedia = useCallback(async () => { if (!activeVersion) return; // If we only have audio, handle it via submitVoiceComment for backwards compatibility conceptually if (audioBlob && !imageBlob && !commentText.trim()) { submitVoiceComment(); return; } if (audioBlob) setIsUploadingAudio(true); if (imageBlob) setIsUploadingImage(true); try { let voiceData: { url: string; duration: number } | undefined; if (audioBlob) { const formData = new FormData(); formData.append('audio', audioBlob, 'recording.webm'); formData.append('videoId', videoId); const uploadToken = await getGuestUploadToken('audio'); if (uploadToken) formData.append('uploadToken', uploadToken); const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData }); if (!uploadRes.ok) throw new Error('Failed to upload audio'); const uploadData = await uploadRes.json(); voiceData = { url: uploadData.data.url, duration: recordingTime }; } await handleAddComment(voiceData); // Image is uploaded inside handleAddComment for both text/image cases setAudioBlob(null); setRecordingTime(0); setImageBlob(null); if (imageInputRef.current) imageInputRef.current.value = ''; } catch (err) { console.error('Failed to submit comment with media:', err); toast.error('Failed to upload media'); } finally { setIsUploadingAudio(false); setIsUploadingImage(false); } }, [audioBlob, imageBlob, activeVersion, recordingTime, commentText, submitVoiceComment, handleAddComment, videoId, getGuestUploadToken]); const handleResolveComment = useCallback( async (commentId: string, currentlyResolved: boolean) => { if (!video?.canResolveComments) { toast.error('Only admins can resolve comments'); return; } isMutatingRef.current = true; setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.map((c) => c.id === commentId ? { ...c, isResolved: !c.isResolved } : c ), } : v ), }; }); try { const res = await fetch(`/api/comments/${commentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isResolved: !currentlyResolved }), }); if (!res.ok) { setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.map((c) => c.id === commentId ? { ...c, isResolved: currentlyResolved } : c ), } : v ), }; }); toast.error('Failed to update comment'); } } catch { setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.map((c) => c.id === commentId ? { ...c, isResolved: currentlyResolved } : c ), } : v ), }; }); toast.error('Failed to update comment'); } finally { isMutatingRef.current = false; } }, [activeVersionId, video?.canResolveComments] ); const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => { if (!voiceData && !replyImageBlob && !replyText.trim()) return; if (!activeVersion) return; const tempId = `temp-reply-${Date.now()}`; const parentComment = comments.find((c) => c.id === parentId); const optimisticReply = { id: tempId, content: (voiceData || replyImageBlob) ? replyText.trim() || null : replyText, voiceUrl: voiceData?.url ?? null, voiceDuration: voiceData?.duration ?? null, imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null, annotationData: null, createdAt: new Date().toISOString(), author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, guestName: isGuest ? normalizedGuestName : null, canEdit: true, canDelete: true, tag: null, }; setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.map((c) => c.id === parentId ? { ...c, replies: [...(c.replies || []), optimisticReply] } : c ), } : v ), }; }); setReplyText(''); setReplyingTo(null); setReplyAudioBlob(null); setReplyRecordingTime(0); setReplyImageBlob(null); setIsSubmittingReply(true); isMutatingRef.current = true; try { let submittedImageData: { url: string } | undefined = imageData; if (replyImageBlob && !imageData) { setIsUploadingReplyImage(true); const imageFormData = new FormData(); imageFormData.append('image', replyImageBlob); imageFormData.append('videoId', videoId); const uploadToken = await getGuestUploadToken('image'); if (uploadToken) imageFormData.append('uploadToken', uploadToken); const imageRes = await fetch('/api/upload/image', { method: 'POST', body: imageFormData, }); if (!imageRes.ok) throw new Error('Failed to upload image reply'); const imageDataResponse = await imageRes.json(); submittedImageData = { url: imageDataResponse.data.url }; } const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: (voiceData || submittedImageData) ? replyText.trim() || null : replyText, timestamp: parentComment?.timestamp ?? currentTime, parentId, ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(submittedImageData && { imageUrl: submittedImageData.url }), ...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }), }), }); if (res.ok) { const response = await res.json(); const newReply = response.data; setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.map((c) => c.id === parentId ? { ...c, replies: (c.replies || []).map(r => r.id === tempId ? newReply : r) } : { ...c, replies: c.replies || [] } ), } : v ), }; }); } else { setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.map((c) => c.id === parentId ? { ...c, replies: (c.replies || []).filter(r => r.id !== tempId) } : c ), } : v ), }; }); toast.error('Failed to add reply'); } } catch { setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.map((c) => c.id === parentId ? { ...c, replies: (c.replies || []).filter(r => r.id !== tempId) } : c ), } : v ), }; }); toast.error('Failed to add reply'); } finally { setIsSubmittingReply(false); setIsUploadingReplyImage(false); isMutatingRef.current = false; } }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, normalizedGuestName, currentUserName, replyImageBlob, videoId, getGuestUploadToken]); const startReplyRecording = useCallback(async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm', }); replyAudioChunksRef.current = []; replyMediaRecorderRef.current = mediaRecorder; mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) replyAudioChunksRef.current.push(e.data); }; mediaRecorder.onstop = () => { const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' }); setReplyAudioBlob(blob); stream.getTracks().forEach((track) => track.stop()); if (replyRecordingTimerRef.current) { clearInterval(replyRecordingTimerRef.current); replyRecordingTimerRef.current = null; } }; mediaRecorder.start(100); setIsReplyRecording(true); setReplyRecordingTime(0); replyRecordingTimerRef.current = setInterval(() => { setReplyRecordingTime((prev) => prev + 0.1); }, 100); } catch (err) { console.error('Failed to start reply recording:', err); } }, []); const stopReplyRecording = useCallback(() => { if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') { replyMediaRecorderRef.current.stop(); } setIsReplyRecording(false); }, []); const cancelReplyRecording = useCallback(() => { if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') { replyMediaRecorderRef.current.stop(); } setIsReplyRecording(false); setReplyAudioBlob(null); setReplyRecordingTime(0); }, []); const submitVoiceReply = useCallback(async (parentId: string) => { if (!replyAudioBlob || !activeVersion) return; setIsUploadingReplyAudio(true); try { const formData = new FormData(); formData.append('audio', replyAudioBlob, 'recording.webm'); formData.append('videoId', videoId); const uploadToken = await getGuestUploadToken('audio'); if (uploadToken) formData.append('uploadToken', uploadToken); const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData }); if (!uploadRes.ok) throw new Error('Failed to upload audio'); const uploadData = await uploadRes.json(); const { url } = uploadData.data; await handleReplyComment(parentId, { url, duration: replyRecordingTime }); } catch (err) { console.error('Failed to submit voice reply:', err); } finally { setIsUploadingReplyAudio(false); } }, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment, videoId, getGuestUploadToken]); const submitReplyWithMedia = useCallback(async (parentId: string) => { if (!activeVersion) return; if (replyAudioBlob && !replyImageBlob && !replyText.trim()) { submitVoiceReply(parentId); return; } if (replyAudioBlob) setIsUploadingReplyAudio(true); if (replyImageBlob) setIsUploadingReplyImage(true); try { let voiceData: { url: string; duration: number } | undefined; if (replyAudioBlob) { const formData = new FormData(); formData.append('audio', replyAudioBlob, 'recording.webm'); formData.append('videoId', videoId); const uploadToken = await getGuestUploadToken('audio'); if (uploadToken) formData.append('uploadToken', uploadToken); const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData }); if (!uploadRes.ok) throw new Error('Failed to upload audio reply'); const uploadData = await uploadRes.json(); voiceData = { url: uploadData.data.url, duration: replyRecordingTime }; } await handleReplyComment(parentId, voiceData); setReplyAudioBlob(null); setReplyRecordingTime(0); setReplyImageBlob(null); if (replyImageInputRef.current) replyImageInputRef.current.value = ''; } catch (err) { console.error('Failed to submit reply with media:', err); toast.error('Failed to upload media'); } finally { setIsUploadingReplyAudio(false); setIsUploadingReplyImage(false); } }, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment, videoId, getGuestUploadToken]); const handleEditComment = useCallback(async (commentId: string) => { if (!editText.trim() && !editAnnotationData) return; setIsSubmittingEdit(true); isMutatingRef.current = true; // Auto-capture strokes from edit canvas if still drawing let finalAnnotationData = editAnnotationData; if (isEditingAnnotation && editAnnotationCanvasRef.current) { const strokes = editAnnotationCanvasRef.current.getStrokes(); if (strokes.length > 0) { finalAnnotationData = JSON.stringify(strokes); } } try { const body: Record = { content: editText }; if (editTagId !== undefined) body.tagId = editTagId; if (finalAnnotationData !== undefined) body.annotationData = finalAnnotationData; if (isGuest && normalizedGuestName) body.guestName = normalizedGuestName; const res = await fetch(`/api/comments/${commentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (res.ok) { const editedTag = editTagId ? availableTags.find(t => t.id === editTagId) || null : null; setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.map((c) => { if (c.id === commentId) return { ...c, content: editText.trim(), tag: editTagId !== undefined ? editedTag : c.tag, annotationData: finalAnnotationData !== undefined ? finalAnnotationData : c.annotationData }; return { ...c, replies: (c.replies || []).map((r) => r.id === commentId ? { ...r, content: editText.trim() } : r ), }; }), } : v ), }; }); setEditingCommentId(null); setEditText(''); setEditTagId(null); setEditAnnotationData(undefined); setIsEditingAnnotation(false); // Update the viewing overlay if it was showing this annotation if (finalAnnotationData !== undefined && finalAnnotationData) { try { setViewingAnnotation(JSON.parse(finalAnnotationData)); } catch { /* ignore parse errors */ } } else if (finalAnnotationData === null) { setViewingAnnotation(null); } } } catch (err) { console.error('Failed to edit comment:', err); } finally { setIsSubmittingEdit(false); isMutatingRef.current = false; } }, [editText, editTagId, editAnnotationData, isEditingAnnotation, activeVersionId, availableTags, isGuest, normalizedGuestName]); const handleDeleteComment = useCallback(async (commentId: string) => { setDeletingCommentId(commentId); isMutatingRef.current = true; const previousVideo = video; setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments .filter((c) => c.id !== commentId) .map((c) => ({ ...c, replies: c.replies?.filter((r) => r.id !== commentId) || [], })), } : v ), }; }); try { const res = await fetch(`/api/comments/${commentId}`, { method: 'DELETE' }); if (!res.ok) { setVideo(previousVideo); } } catch (err) { console.error('Failed to delete comment:', err); setVideo(previousVideo); } finally { setDeletingCommentId(null); isMutatingRef.current = false; } }, [activeVersionId, video]); // Comment polling with Page Visibility API to pause when tab is hidden useEffect(() => { if (!activeVersion) return; let intervalId: ReturnType | null = null; let isPageVisible = true; const poll = async () => { try { if (isMutatingRef.current || !isPageVisible) return; const res = await fetch(apiBasePath, { cache: 'no-store' }); if (res.ok) { const data = await res.json(); if (!isMutatingRef.current) { setVideo(data.data); } } } catch { /* silent */ } }; // Start polling intervalId = setInterval(poll, 10000); // Handle page visibility change const handleVisibilityChange = () => { isPageVisible = document.visibilityState === 'visible'; }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { if (intervalId) clearInterval(intervalId); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [activeVersion, apiBasePath]); const handleNewVersionUrlChange = (url: string) => { setNewVersionUrl(url); setNewVersionUrlError(''); if (!url.trim()) { setNewVersionSource(null); return; } const source = parseVideoUrl(url); if (source) { setNewVersionSource(source); } else { setNewVersionSource(null); if (url.length > 10) setNewVersionUrlError('Unsupported URL'); } }; const handleCreateVersion = async () => { if (!propProjectId) return; setIsCreatingVersion(true); setNewVersionUploadStatus(''); setNewVersionUploadProgress(0); let uploadedBunnyVideoId: string | null = null; let uploadedBunnyUploadToken: string | null = null; try { let finalVideoUrl = ''; let finalProviderId = ''; let finalProviderVideoId = ''; let finalThumbnailUrl: string | null = null; let finalDuration: number | null = null; if (newVersionMode === 'url') { if (!newVersionSource) throw new Error('Invalid URL'); const meta = await fetchVideoMetadata(newVersionSource); finalVideoUrl = newVersionSource.originalUrl; finalProviderId = newVersionSource.providerId; finalProviderVideoId = newVersionSource.videoId; finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large'); finalDuration = meta?.duration || null; } else { if (!newVersionFile) throw new Error('No file selected'); let title = newVersionFile.name; if (newVersionLabel.trim()) { title = newVersionLabel.trim(); } else { title = title.replace(/\.[^/.]+$/, ''); } setNewVersionUploadStatus('Initializing upload...'); const initRes = await fetch(`/api/projects/${propProjectId}/videos/bunny-init`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) }); if (!initRes.ok) throw new Error('Failed to initialize upload'); const { data: { videoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json(); uploadedBunnyVideoId = videoId; uploadedBunnyUploadToken = uploadToken; await new Promise((resolve, reject) => { setNewVersionUploadStatus('Uploading video...'); const upload = new tus.Upload(newVersionFile, { endpoint: 'https://video.bunnycdn.com/tusupload', retryDelays: [0, 3000, 5000, 10000, 20000], headers: { AuthorizationSignature: signature, AuthorizationExpire: expirationTime.toString(), VideoId: videoId, LibraryId: libraryId, }, metadata: { filetype: newVersionFile.type, title: title, }, onError: (error) => reject(new Error('Upload failed: ' + error.message)), onProgress: (bytesUploaded, bytesTotal) => { const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1); setNewVersionUploadProgress(Number(percentage)); setNewVersionUploadStatus(`Uploading... ${percentage}%`); }, onSuccess: () => { setNewVersionUploadStatus('Processing video...'); resolve(true); }, }); upload.start(); }); finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`; finalProviderId = 'bunny'; finalProviderVideoId = videoId; finalThumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${videoId}/thumbnail.jpg`; } const res = await fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ videoUrl: finalVideoUrl, providerId: finalProviderId, providerVideoId: finalProviderVideoId, uploadToken: uploadedBunnyUploadToken, versionLabel: newVersionLabel.trim() || null, thumbnailUrl: finalThumbnailUrl, duration: finalDuration, setActive: true, }), }); if (!res.ok) { const data = await res.json().catch(() => null); throw new Error(data?.error || 'Failed to create version'); } const versionData = await res.json(); const newVersion = versionData.data; // Optimistically add the new version to local state instead of refetching setVideo((prev) => { if (!prev) return prev; const updatedVersions = prev.versions.map(v => ({ ...v, isActive: false })); const createdVersion = { ...newVersion, comments: [], }; updatedVersions.unshift(createdVersion); return { ...prev, versions: updatedVersions }; }); setActiveVersionId(newVersion.id); setShowVersionDialog(false); setNewVersionUrl(''); setNewVersionLabel(''); setNewVersionSource(null); setNewVersionFile(null); setNewVersionUploadStatus(''); } catch (err) { const errorObj = err as Error; if (uploadedBunnyVideoId && uploadedBunnyUploadToken) { await fetch(`/api/projects/${propProjectId}/videos/bunny-init`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ videoId: uploadedBunnyVideoId, uploadToken: uploadedBunnyUploadToken }), }).catch((cleanupError) => { console.error('Failed to cleanup pending Bunny version upload:', cleanupError); }); } console.error('Failed to create version:', errorObj); toast.error(errorObj.message || 'Failed to create version'); } finally { setIsCreatingVersion(false); } }; // Version deletion const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false); const [versionToDelete, setVersionToDelete] = useState(null); const [isDeletingVersion, setIsDeletingVersion] = useState(false); const handleDeleteVersion = async () => { if (!versionToDelete || !propProjectId) return; setIsDeletingVersion(true); try { const res = await fetch( `/api/projects/${propProjectId}/videos/${videoId}/versions/${versionToDelete}`, { method: 'DELETE' } ); if (res.ok) { setVideo((prev) => { if (!prev) return prev; const remaining = prev.versions.filter((v) => v.id !== versionToDelete); return { ...prev, versions: remaining }; }); // If deleted version was active, switch to the first remaining if (activeVersionId === versionToDelete && video) { const remaining = video.versions.filter((v) => v.id !== versionToDelete); if (remaining.length > 0) setActiveVersionId(remaining[0].id); } setShowDeleteVersionDialog(false); setVersionToDelete(null); } else { const data = await res.json(); toast.error(data.error || 'Failed to delete version'); } } catch { toast.error('Failed to delete version'); } finally { setIsDeletingVersion(false); } }; const containerHeight = 'h-screen'; const backHref = mode === 'dashboard' ? `/projects/${propProjectId}` : (video?.projectId ? `/projects/${video.projectId}` : '/'); const isBunnyVersion = activeVersion?.providerId === 'bunny'; const showBunnyProcessingOverlay = isBunnyVersion && bunnyPlaybackState === 'processing'; const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error'; if (loading) { return (
{mode === 'dashboard' && }
{Array.from({ length: 5 }).map((_, i) => (
))}
); } if (error || !video || !activeVersion) { return (

{error || 'Video not found'}

); } if (mode === 'watch' && isGuest && !guestNameConfirmed) { return (

Welcome to OpenFrame

Enter your name to view and comment on this video

setGuestName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && guestName.trim()) { localStorage.setItem('openframe_guest_name', guestName.trim()); setGuestNameConfirmed(true); } }} autoFocus />

Or{' '} sign in {' '} for a full account

); } return (
isDragging && handleTimelineMouseUp()} >
Back
{video.title} • {video.project.name}
{video.versions.map((version) => ( handleVersionSelect(version.id)} > v{version.versionNumber} {version.versionLabel || `Version ${version.versionNumber}`} {version._count.comments} comments ))} {mode === 'dashboard' && video.versions.length > 1 && ( <> { setVersionToDelete(activeVersionId); setShowDeleteVersionDialog(true); }} > Delete Current Version )} {/* Version Delete Confirmation */} Delete this version? This will permanently delete this version and all its comments. This cannot be undone. Cancel {isDeletingVersion && } Delete Version {activeVersion?.providerId === 'bunny' ? ( { event.preventDefault(); void handleDownloadVideo('original'); }} disabled={!isVideoDownloadAvailable || isDownloadingVideo} > {activeDownloadTarget === 'original' ? ( ) : ( )} Download Original { event.preventDefault(); void handleDownloadVideo('compressed'); }} disabled={!isVideoDownloadAvailable || isDownloadingVideo} > {activeDownloadTarget === 'compressed' ? ( ) : ( )} Download Compressed ) : ( )} {mode === 'dashboard' && ( <>
Add New Version Upload a new version of this video. The new version will become active.
setNewVersionMode(v as 'url' | 'file')} className="mb-2"> Link URL Upload File {newVersionMode === 'url' ? (
handleNewVersionUrlChange(e.target.value)} className="pl-10" disabled={isCreatingVersion} />
{newVersionUrlError && (

{newVersionUrlError}

)} {newVersionSource && (

{newVersionSource.providerId.charAt(0).toUpperCase() + newVersionSource.providerId.slice(1)}{' '} video detected

)}
) : (
)}
setNewVersionLabel(e.target.value)} disabled={isCreatingVersion} />
{newVersionUploadStatus && (

{newVersionUploadStatus}

{newVersionUploadProgress > 0 && newVersionUploadProgress < 100 && (
)}
)}
{video.versions.length >= 2 && ( )}
{/* Mobile Actions Dropdown */}
{activeVersion?.providerId === 'bunny' ? ( <> { event.preventDefault(); void handleDownloadVideo('original'); }} disabled={!isVideoDownloadAvailable || isDownloadingVideo} > {activeDownloadTarget === 'original' ? ( ) : ( )} Download Original { event.preventDefault(); void handleDownloadVideo('compressed'); }} disabled={!isVideoDownloadAvailable || isDownloadingVideo} > {activeDownloadTarget === 'compressed' ? ( ) : ( )} Download Compressed ) : ( { event.preventDefault(); void handleDownloadVideo(); }} disabled={!isVideoDownloadAvailable || isDownloadingVideo} > {isDownloadingVideo ? ( ) : ( )} Download )} setShowVersionDialog(true)}> New Version {video.versions.length >= 2 && ( { setSelectedCompareVersions(new Set(activeVersionId ? [activeVersionId] : [])); setShowCompareDialog(true); }}> Compare )}
)}
{activeVersion?.providerId === 'bunny' ? (
0 ? { width: `${bunnyPortraitFrameWidth}px` } : undefined} >
) : (