diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index c2d9755..6b59f42 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -1,174 +1,36 @@ 'use client'; import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; -import Hls, { type Level } from 'hls.js'; -import Link from 'next/link'; +import Hls from 'hls.js'; 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; -} +import { type AnnotationStroke, type AnnotationCanvasHandle } from '@/components/annotation-canvas'; +import { PlayerCore } from '@/components/video-page/player-core'; +import { VideoPageHeader } from '@/components/video-page/video-page-header'; +import { ImagePreviewDialog } from '@/components/video-page/image-preview-dialog'; +import { CompareVersionsDialog } from '@/components/video-page/compare-versions-dialog'; +import { VideoPageLoading } from '@/components/video-page/video-page-loading'; +import { VideoPageError } from '@/components/video-page/video-page-error'; +import { GuestNameGate } from '@/components/video-page/guest-name-gate'; +import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media'; +import { useVersionActions } from '@/components/video-page/hooks/use-version-actions'; +import { useWatchProgress } from '@/components/video-page/hooks/use-watch-progress'; +import { useVideoPlayer } from '@/components/video-page/hooks/use-video-player'; +import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions'; +import { useVideoPageData } from '@/components/video-page/hooks/use-video-page-data'; +import { useCommentExport } from '@/components/video-page/hooks/use-comment-export'; +import { useDownloadActions } from '@/components/video-page/hooks/use-download-actions'; +import { useVersionDurationSync } from '@/components/video-page/hooks/use-version-duration-sync'; +import { CommentComposer } from '@/components/video-page/comment-composer'; +import { CommentsPane } from '@/components/video-page/comments-pane'; +import type { + CommentMarker, + PlayerAdapter, + VideoPageCommentsActions, + VideoPageCompareActions, + VideoPageComposerActions, + VideoPageHeaderActions, +} from '@/components/video-page/types'; function formatTime(seconds: number): string { const totalSeconds = Math.floor(seconds); @@ -191,61 +53,8 @@ function formatBunnyQualityLabel(level: { height?: number; bitrate?: number }, i 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'; @@ -264,95 +73,24 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi 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 scheduleWatchProgressSaveRef = useRef<(input: { + progress: number; + duration?: number; + immediate?: boolean; + force?: boolean; + }) => void>(() => {}); - 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 { + playingVoiceId, + voiceProgress, + voiceCurrentTime, + voicePlaybackRate, + playVoice, + toggleVoiceSpeed, + } = useCommentMedia(); 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 videoDurationRef = useRef(0); - const progressSaveTimerRef = useRef | null>(null); - const progressDebounceTimerRef = useRef | null>(null); - const progressWriteInFlightRef = useRef(false); - const pendingProgressPayloadRef = useRef<{ progress: number; duration: number; force: boolean } | 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 commentsEtagRef = useRef>(new Map()); - const [previewImage, setPreviewImage] = useState(null); // Annotation state const [isAnnotating, setIsAnnotating] = useState(false); @@ -360,250 +98,94 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const [viewingAnnotation, setViewingAnnotation] = useState(null); const annotationCanvasRef = useRef(null); - const [guestName, setGuestName] = useState(''); - const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard'); + const [guestName, setGuestName] = useState(() => { + if (typeof window === 'undefined') return ''; + return localStorage.getItem('openframe_guest_name') || ''; + }); + const [guestNameConfirmed, setGuestNameConfirmed] = useState(() => { + if (mode === 'dashboard') return true; + if (typeof window === 'undefined') return false; + return !!localStorage.getItem('openframe_guest_name'); + }); // 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 { + video, + setVideo, + loading, + error, + activeVersionId, + setActiveVersionId, + availableTags, + selectedTagId, + setSelectedTagId, + projectId, + fetchVersionComments, + } = useVideoPageData({ + mode, + videoId, + propProjectId, + }); 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; + const { + showVersionDialog, + setShowVersionDialog, + newVersionUrl, + newVersionLabel, + setNewVersionLabel, + newVersionSource, + newVersionUrlError, + isCreatingVersion, + newVersionMode, + setNewVersionMode, + newVersionFile, + setNewVersionFile, + newVersionUploadProgress, + newVersionUploadStatus, + handleNewVersionUrlChange, + handleCreateVersion, + showDeleteVersionDialog, + setShowDeleteVersionDialog, + setVersionToDelete, + isDeletingVersion, + handleDeleteVersion, + } = useVersionActions({ + projectId: propProjectId, + videoId, + setVideo, + activeVersionId, + setActiveVersionId, + }); // 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); - }, []); + }, [setActiveVersionId]); // 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); - }; - }, []); + const { isExportingCsv, isExportingPdf, exportComments } = useCommentExport({ + activeVersionId, + showResolved, + }); // 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}?includeComments=false` - : `/api/watch/${videoId}`; - - const fetchVersionComments = useCallback(async (versionId: string, useEtag: boolean) => { - const headers: HeadersInit = {}; - if (useEtag) { - const etag = commentsEtagRef.current.get(versionId); - if (etag) headers['If-None-Match'] = etag; - } - - const res = await fetch(`/api/versions/${versionId}/comments?includeResolved=true`, { - cache: 'no-store', - headers, - }); - - if (res.status === 304) return; - if (!res.ok) return; - - const etag = res.headers.get('etag'); - if (etag) commentsEtagRef.current.set(versionId, etag); - - const payload = await res.json(); - const commentsList = payload?.data?.comments; - if (!Array.isArray(commentsList)) return; - - setVideo((prev) => { - if (!prev) return prev; - const totalComments = commentsList.reduce((sum: number, comment: Comment) => { - return sum + 1 + (comment.replies?.length ?? 0); - }, 0); - - return { - ...prev, - versions: prev.versions.map((version) => ( - version.id === versionId - ? { ...version, comments: commentsList, _count: { comments: totalComments } } - : version - )), - }; - }); - }, []); - - 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 rawData = response.data as Omit & { - versions?: Array; - }; - const normalizedData: VideoData = { - ...rawData, - versions: (rawData.versions || []).map((version) => ({ - ...version, - comments: Array.isArray(version.comments) ? version.comments : [], - })), - }; - - setVideo(normalizedData); - const active = normalizedData.versions?.find((v) => v.isActive) || normalizedData.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]); - - useEffect(() => { - if (!activeVersionId) return; - void fetchVersionComments(activeVersionId, true); - }, [activeVersionId, fetchVersionComments]); - // Memoize active version lookup to avoid recalculating on every render const activeVersion = useMemo(() => { return video?.versions?.find((v) => v.id === activeVersionId) || @@ -612,123 +194,6 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi }, [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') { @@ -751,2039 +216,227 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } }, [activeVersion]); + const { + isReady, + bunnyPlaybackState, + currentTime, + setCurrentTime, + videoDuration, + isPlaying, + isMuted, + isDragging, + playbackSpeed, + qualityOptions, + selectedQualityLevel, + isBunnyPortraitSource, + bunnyPortraitFrameWidth, + cursorIdle, + isFullscreenMode, + showComments, + isMobileCommentsOpen, + setShowComments, + setIsMobileCommentsOpen, + handleVideoMouseMove, + handleVideoMouseLeave, + handlePlayPause, + handleSeekToTimestamp, + handleMuteToggle, + handleSkip, + handleSpeedChange, + handleQualityChange, + handleTimelineMouseDown, + handleTimelineMouseMove, + handleTimelineMouseUp, + toggleFullscreen, + } = useVideoPlayer({ + activeVersion, + activeVersionId, + activeProviderId, + embedUrl, + canInitializePlayer, + iframeRef, + videoRef, + bunnyViewportRef, + timelineRef, + hlsRef, + playerRef, + formatBunnyQualityLabel, + speedOptions: SPEED_OPTIONS, + scheduleWatchProgressSaveRef, + setViewingAnnotation, + }); + + const { + savedProgress, + showResumePrompt, + scheduleWatchProgressSave, + handleResumeFromSaved, + handleDismissResume, + } = useWatchProgress({ + videoId, + activeVersionId, + isAuthenticated: !!video?.isAuthenticated, + pathname, + playerRef, + isReady, + currentTime, + videoDuration, + }); + + useEffect(() => { + scheduleWatchProgressSaveRef.current = scheduleWatchProgressSave; + }, [scheduleWatchProgressSave]); + + const handleResumeFromSavedWithSync = useCallback(() => { + const resumed = handleResumeFromSaved(); + if (typeof resumed === 'number') { + setCurrentTime(resumed); + } + }, [handleResumeFromSaved, setCurrentTime]); + + const { activeDownloadTarget, isDownloadingVideo, startDownload } = useDownloadActions({ + activeVersion, + video, + }); + + // 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]); + 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() { + const { + commentText, + setCommentText, + isSubmittingComment, + isRecording, + recordingTime, + audioBlob, + isUploadingAudio, + imageBlob, + setImageBlob, + isUploadingImage, + imageInputRef, + handleAddComment, + handleImageSelect, + handlePaste, + startRecording, + stopRecording, + cancelRecording, + submitCommentWithMedia, + replyingTo, + setReplyingTo, + replyText, + setReplyText, + isSubmittingReply, + isReplyRecording, + replyRecordingTime, + replyAudioBlob, + replyImageBlob, + setReplyImageBlob, + isUploadingReplyAudio, + isUploadingReplyImage, + replyImageInputRef, + handleReplyComment, + startReplyRecording, + stopReplyRecording, + cancelReplyRecording, + submitReplyWithMedia, + editingCommentId, + setEditingCommentId, + editText, + setEditText, + editTagId, + setEditTagId, + editAnnotationData, + setEditAnnotationData, + isEditingAnnotation, + setIsEditingAnnotation, + isSubmittingEdit, + handleEditComment, + handleDeleteComment, + handleResolveComment, + previewImage, + setPreviewImage, + } = useCommentActions({ + videoId, + setVideo, + activeVersionId, + activeVersion, + comments, + currentTime, + isGuest, + normalizedGuestName, + currentUserName, + canResolveComments, + availableTags, + selectedTagId, + setSelectedTagId, + annotationStrokes, + setAnnotationStrokes, + isAnnotating, + setIsAnnotating, + setViewingAnnotation, + annotationCanvasRef, + editAnnotationCanvasRef, + fetchVersionComments, + }); + + const commentMarkers = useMemo(() => { + return filteredComments.map((comment) => ({ + id: comment.id, + timestamp: comment.timestamp, + color: comment.tag?.color || (comment.isResolved ? '#22C55E' : '#22D3EE'), + annotationData: comment.annotationData, + preview: `${comment.tag ? ` [${comment.tag.name}]` : ''} - ${comment.content?.substring(0, 30) || '(voice note)'}...`, + })); + }, [filteredComments]); + + const editAnnotationInitialStrokes = useMemo(() => { + if (editAnnotationData) { 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); - } - } + return JSON.parse(editAnnotationData) as AnnotationStroke[]; } catch { + return undefined; } } - 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]); - - const flushScheduledWatchProgress = useCallback(async () => { - if (!video?.isAuthenticated || !activeVersionId || progressWriteInFlightRef.current) return; - - const nextPayload = pendingProgressPayloadRef.current; - if (!nextPayload) return; - - if (!nextPayload.force && Math.abs(nextPayload.progress - lastSavedProgressRef.current) < 2) { - pendingProgressPayloadRef.current = null; - return; - } - - pendingProgressPayloadRef.current = null; - progressWriteInFlightRef.current = true; + const editingComment = comments.find((comment) => comment.id === editingCommentId); + if (!editingComment?.annotationData) return undefined; try { - const response = await fetch(`/api/watch/${videoId}/progress`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - progress: nextPayload.progress, - duration: nextPayload.duration, - versionId: activeVersionId, - }), - }); - if (response.ok) { - lastSavedProgressRef.current = nextPayload.progress; - } - } catch (err) { - console.error('Error saving watch progress:', err); - } finally { - progressWriteInFlightRef.current = false; - if (pendingProgressPayloadRef.current) { - void flushScheduledWatchProgress(); - } - } - }, [video?.isAuthenticated, activeVersionId, videoId]); - - const scheduleWatchProgressSave = useCallback((input: { - progress: number; - duration?: number; - immediate?: boolean; - force?: boolean; - }) => { - if (!video?.isAuthenticated || !activeVersionId) return; - - const progress = Math.max(0, input.progress); - if (progress <= 0) return; - - const duration = Math.max(0, input.duration ?? videoDurationRef.current ?? 0); - const force = input.force ?? false; - - if (!force && Math.abs(progress - lastSavedProgressRef.current) < 2) { - return; - } - - const existingPayload = pendingProgressPayloadRef.current; - pendingProgressPayloadRef.current = existingPayload - ? { - progress: Math.max(existingPayload.progress, progress), - duration: Math.max(existingPayload.duration, duration), - force: existingPayload.force || force, - } - : { progress, duration, force }; - - if (input.immediate) { - if (progressDebounceTimerRef.current) { - clearTimeout(progressDebounceTimerRef.current); - progressDebounceTimerRef.current = null; - } - void flushScheduledWatchProgress(); - return; - } - - if (progressDebounceTimerRef.current) { - clearTimeout(progressDebounceTimerRef.current); - } - - progressDebounceTimerRef.current = setTimeout(() => { - progressDebounceTimerRef.current = null; - void flushScheduledWatchProgress(); - }, 800); - }, [video?.isAuthenticated, activeVersionId, flushScheduledWatchProgress]); - - useEffect(() => { - return () => { - if (progressDebounceTimerRef.current) { - clearTimeout(progressDebounceTimerRef.current); - progressDebounceTimerRef.current = null; - } - }; - }, []); - - useEffect(() => { - videoDurationRef.current = videoDuration; - }, [videoDuration]); - - useEffect(() => { - lastSavedProgressRef.current = 0; - pendingProgressPayloadRef.current = null; - progressWriteInFlightRef.current = false; - if (progressDebounceTimerRef.current) { - clearTimeout(progressDebounceTimerRef.current); - progressDebounceTimerRef.current = null; - } - }, [videoId, activeVersionId]); - - 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; - scheduleWatchProgressSave({ - progress: playerCurrentTime, - duration: playerDuration, - immediate: true, - force: true, - }); - } - - 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; - scheduleWatchProgressSave({ - progress: current, - duration, - immediate: true, - force: true, - }); - }; - - 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, scheduleWatchProgressSave]); - - // 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(() => { - if (playerRef.current?.getCurrentTime) { - scheduleWatchProgressSave({ - progress: playerRef.current.getCurrentTime(), - duration: playerRef.current.getDuration?.() || videoDuration, - }); - } - }, 5000); - - return () => { - if (progressSaveTimerRef.current) { - clearInterval(progressSaveTimerRef.current); - progressSaveTimerRef.current = null; - } - }; - }, [video?.isAuthenticated, isReady, videoDuration, activeVersionId, scheduleWatchProgressSave]); - - 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; - const pendingPayload = pendingProgressPayloadRef.current; - const finalProgress = Math.max(playerCurrentTime, pendingPayload?.progress ?? 0); - const finalDuration = Math.max(playerDuration, pendingPayload?.duration ?? 0); - - if (finalProgress > 0 && navigator.sendBeacon && activeVersionId) { - if (progressDebounceTimerRef.current) { - clearTimeout(progressDebounceTimerRef.current); - progressDebounceTimerRef.current = null; - } - // Use sendBeacon for reliable save on page unload - const data = new Blob([JSON.stringify({ - progress: finalProgress, - duration: finalDuration, - 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') { - scheduleWatchProgressSave({ - progress: playerCurrentTime, - duration: playerDuration, - immediate: true, - force: true, - }); - } - }; - - window.addEventListener('beforeunload', saveProgressOnLeave); - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => { - window.removeEventListener('beforeunload', saveProgressOnLeave); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId, scheduleWatchProgressSave]); - - 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: 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(''); - 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: 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'); - } + return JSON.parse(editingComment.annotationData) as AnnotationStroke[]; } 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; + return undefined; } - }, [commentText, currentTime, 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 (!activeVersionId) return; - - let intervalId: ReturnType | null = null; - let isPageVisible = true; - - const poll = async () => { - try { - if (isMutatingRef.current || !isPageVisible) return; - await fetchVersionComments(activeVersionId, true); - } 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); - }; - }, [activeVersionId, fetchVersionComments]); - - 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); - } - }; - + }, [editAnnotationData, comments, editingCommentId]); + + useVersionDurationSync({ + videoDuration, + activeVersionDuration, + activeVersionId, + propProjectId, + videoId, + setVideo, + }); const containerHeight = 'h-screen'; const backHref = mode === 'dashboard' @@ -2793,138 +446,114 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const showBunnyProcessingOverlay = isBunnyVersion && bunnyPlaybackState === 'processing'; const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error'; + const confirmGuestName = useCallback(() => { + if (!guestName.trim()) return; + localStorage.setItem('openframe_guest_name', guestName.trim()); + setGuestNameConfirmed(true); + }, [guestName]); + + const handleDeleteCurrentVersionClick = useCallback(() => { + setVersionToDelete(activeVersionId); + setShowDeleteVersionDialog(true); + }, [activeVersionId, setShowDeleteVersionDialog, setVersionToDelete]); + + const handleOpenCompare = useCallback(() => { + setSelectedCompareVersions(new Set(activeVersionId ? [activeVersionId] : [])); + setShowCompareDialog(true); + }, [activeVersionId]); + + const toggleCompareVersion = useCallback((versionId: string) => { + setSelectedCompareVersions((prev) => { + const next = new Set(prev); + if (next.has(versionId)) { + next.delete(versionId); + } else { + next.add(versionId); + } + return next; + }); + }, []); + + const handleCompareConfirm = useCallback(() => { + const ids = Array.from(selectedCompareVersions).join(','); + setShowCompareDialog(false); + router.push(`/projects/${propProjectId}/videos/${videoId}/compare?versions=${ids}`); + }, [propProjectId, router, selectedCompareVersions, videoId]); + + const handleStartEditAnnotation = useCallback(() => { + if (playerRef.current?.pauseVideo) { + playerRef.current.pauseVideo(); + } + setIsEditingAnnotation(true); + setIsAnnotating(false); + }, [setIsEditingAnnotation]); + + const pauseVideoForAnnotation = useCallback(() => { + if (playerRef.current?.pauseVideo) { + playerRef.current.pauseVideo(); + } + }, []); + + const headerActions: VideoPageHeaderActions = useMemo(() => ({ + onVersionSelect: handleVersionSelect, + onDeleteCurrentVersionClick: handleDeleteCurrentVersionClick, + onDownload: startDownload, + onOpenCompare: handleOpenCompare, + onCreateVersion: handleCreateVersion, + }), [handleVersionSelect, handleDeleteCurrentVersionClick, startDownload, handleOpenCompare, handleCreateVersion]); + + const commentsActions: VideoPageCommentsActions = useMemo(() => ({ + onExportComments: exportComments, + onResolveComment: handleResolveComment, + onEditComment: handleEditComment, + onDeleteComment: handleDeleteComment, + onReplyComment: handleReplyComment, + onSubmitReplyWithMedia: submitReplyWithMedia, + onStartEditAnnotation: handleStartEditAnnotation, + }), [exportComments, handleResolveComment, handleEditComment, handleDeleteComment, handleReplyComment, submitReplyWithMedia, handleStartEditAnnotation]); + + const composerActions: VideoPageComposerActions = useMemo(() => ({ + onSubmitCommentWithMedia: submitCommentWithMedia, + onAddComment: handleAddComment, + onPauseVideoForAnnotation: pauseVideoForAnnotation, + }), [submitCommentWithMedia, handleAddComment, pauseVideoForAnnotation]); + + const compareActions: VideoPageCompareActions = useMemo(() => ({ + onToggleVersion: toggleCompareVersion, + onCompare: handleCompareConfirm, + }), [toggleCompareVersion, handleCompareConfirm]); + 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 -

-
-
+ ); } @@ -2936,1776 +565,223 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi >
-
-
- - - 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} - > -
-
- ) : ( -