'use client'; import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { List } from 'react-window'; import Link from 'next/link'; import { usePathname } 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, } 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'; 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 Comment { id: string; content: string | null; timestamp: number; voiceUrl: string | null; voiceDuration: number | null; isResolved: boolean; createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; tag: CommentTag | null; replies: { id: string; content: string | null; voiceUrl: string | null; voiceDuration: number | null; createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; 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; } function formatTime(seconds: number): string { const totalSeconds = Math.floor(seconds); const hrs = Math.floor(totalSeconds / 3600); const mins = Math.floor((totalSeconds % 3600) / 60); const secs = totalSeconds % 60; if (hrs > 0) { return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } return `${mins}:${secs.toString().padStart(2, '0')}`; } const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; export type VideoPageMode = 'dashboard' | 'watch'; interface VideoPageContentProps { mode: VideoPageMode; videoId: string; projectId?: string; } export function VideoPageContent({ mode, videoId, projectId: propProjectId }: VideoPageContentProps) { const iframeRef = useRef(null); const playerRef = useRef(null); const timelineRef = useRef(null); const videoContainerRef = useRef(null); const pathname = usePathname(); const [video, setVideo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [activeVersionId, setActiveVersionId] = useState(null); const [isReady, setIsReady] = useState(false); const [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 [playbackSpeed, setPlaybackSpeed] = useState(1); const [cursorIdle, setCursorIdle] = useState(false); const cursorIdleTimerRef = useRef | null>(null); const lastPathnameRef = useRef(pathname); const [commentText, setCommentText] = useState(''); const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [isRecording, setIsRecording] = useState(false); const [recordingTime, setRecordingTime] = useState(0); const [audioBlob, setAudioBlob] = useState(null); const [isUploadingAudio, setIsUploadingAudio] = useState(false); const [playingVoiceId, setPlayingVoiceId] = useState(null); const [voiceProgress, setVoiceProgress] = useState(0); const [voiceCurrentTime, setVoiceCurrentTime] = useState(0); const [voicePlaybackRate, setVoicePlaybackRate] = useState(1); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const recordingTimerRef = useRef | null>(null); const audioPlayerRef = useRef(null); const voiceRafRef = useRef(null); const voiceKnownDurationRef = useRef(0); const [selectedTimestamp, setSelectedTimestamp] = useState(null); const [showResolved, setShowResolved] = useState(false); // Watch progress state const [savedProgress, setSavedProgress] = useState(null); const [showResumePrompt, setShowResumePrompt] = useState(false); const progressSaveTimerRef = useRef | null>(null); const lastSavedProgressRef = useRef(0); // Fullscreen state const [isFullscreenMode, setIsFullscreenMode] = useState(false); const [showComments, setShowComments] = useState(true); // 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 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 [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [deletingCommentId, setDeletingCommentId] = useState(null); const isMutatingRef = useRef(false); const [guestName, setGuestName] = useState(''); const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard'); useEffect(() => { const saved = localStorage.getItem('openframe_guest_name'); if (saved) { setGuestName(saved); if (mode === 'watch') setGuestNameConfirmed(true); } }, [mode]); const isGuest = video ? !video.isAuthenticated : false; const [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 [availableTags, setAvailableTags] = useState([]); const [selectedTagId, setSelectedTagId] = useState(null); const projectId = propProjectId || video?.projectId; // Cursor idle detection: hide overlay when cursor idle for 3s while playing // Memoize version selection handler to prevent recreating on each render const handleVersionSelect = useCallback((versionId: string) => { setActiveVersionId(versionId); }, []); // Memoize toggle show resolved handler const handleToggleShowResolved = useCallback(() => { setShowResolved(prev => !prev); }, []); const handleVideoMouseMove = useCallback(() => { setCursorIdle(false); if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); // In fullscreen mode: hide header AND controls when cursor idle for 1s while playing // Non-fullscreen: hide only the play overlay (existing behavior) const shouldHideControls = isFullscreenMode; if (isPlaying || shouldHideControls) { cursorIdleTimerRef.current = setTimeout(() => { setCursorIdle(true); }, 1000); } }, [isFullscreenMode, isPlaying]); const handleVideoMouseLeave = useCallback(() => { if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); setCursorIdle(false); }, []); useEffect(() => { return () => { if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); }; }, []); // Determine current user info for permission checks and comment display const currentUserId = video?.currentUserId || null; const currentUserName = video?.currentUserName || null; const apiBasePath = mode === 'dashboard' ? `/api/projects/${propProjectId}/videos/${videoId}` : `/api/watch/${videoId}`; useEffect(() => { async function fetchVideo() { try { const res = await fetch(apiBasePath, { cache: 'no-store' }); if (!res.ok) { const errorText = mode === 'dashboard' ? await res.text() : ''; setError(mode === 'dashboard' ? `Failed to load video: ${res.status} ${errorText}` : 'Video not found or access denied' ); setLoading(false); return; } const response = await res.json(); const data = response.data; setVideo(data); const active = data.versions?.find((v: Version) => v.isActive) || data.versions?.[0]; if (active) setActiveVersionId(active.id); } catch (err) { console.error('Error fetching video:', err); setError('Failed to load video'); } finally { setLoading(false); } } fetchVideo(); }, [apiBasePath, mode]); // Memoize active version lookup to avoid recalculating on every render const activeVersion = useMemo(() => { return video?.versions?.find((v) => v.id === activeVersionId) || video?.versions?.find((v) => v.isActive) || video?.versions?.[0]; }, [video?.versions, activeVersionId]); // 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') { return `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`; } if (activeVersion.providerId === 'vimeo') { return `https://player.vimeo.com/video/${activeVersion.videoId}`; } try { const url = new URL(activeVersion.originalUrl); if (url.protocol !== 'http:' && url.protocol !== 'https:') { return ''; } return activeVersion.originalUrl; } catch { return ''; } }, [activeVersion]); useEffect(() => { if (!projectId) return; async function fetchTags() { try { const res = await fetch(`/api/projects/${projectId}/tags`); if (res.ok) { const data = await res.json(); const tags = data.data || []; setAvailableTags(tags); if (tags.length > 0 && !selectedTagId) { setSelectedTagId(tags[0].id); } } } catch { } } fetchTags(); }, [projectId]); // Load YouTube API immediately on component mount (async, non-blocking) useEffect(() => { // Already loaded if (isApiLoaded) return; // Already in progress if (window.YT) { setIsApiLoaded(true); return; } const tag = document.createElement('script'); tag.src = 'https://www.youtube.com/iframe_api'; const firstScriptTag = document.getElementsByTagName('script')[0]; firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag); window.onYouTubeIframeAPIReady = () => { setIsApiLoaded(true); }; }, [isApiLoaded]); useEffect(() => { if (!activeVersion || activeVersion.providerId !== 'youtube') return; if (!isApiLoaded) return; setIsReady(false); setCurrentTime(0); setVideoDuration(0); setIsPlaying(false); setPlaybackSpeed(1); if (playerRef.current) { try { playerRef.current.destroy(); } catch { /* ignore */ } playerRef.current = null; } const initPlayer = () => { 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); // Save progress immediately when video is paused if (event.data === YT.PlayerState.PAUSED) { // Get current time and duration directly from player instance, not from React state (which may be stale) const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0; const playerDuration = playerRef.current?.getDuration?.() || 0; if (video?.isAuthenticated && playerCurrentTime > 0 && activeVersionId) { fetch(`/api/watch/${videoId}/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ progress: playerCurrentTime, duration: playerDuration, versionId: activeVersionId, }), }).catch((err) => console.error('Error saving watch progress on pause:', err)); } } if (event.data === YT.PlayerState.PLAYING) { const dur = event.target.getDuration(); if (dur > 0) setVideoDuration(dur); } }, }, }); }; const timeout = setTimeout(() => { if (window.YT?.Player) { initPlayer(); } else { window.onYouTubeIframeAPIReady = initPlayer; } }, 100); return () => { clearTimeout(timeout); window.onYouTubeIframeAPIReady = undefined; }; }, [activeVersionId, isApiLoaded]); // Save detected duration to DB if the version doesn't have one stored useEffect(() => { if (!videoDuration || !activeVersion || !propProjectId) return; if (activeVersion.duration && activeVersion.duration > 0) return; const roundedDuration = Math.round(videoDuration); // Fire-and-forget PATCH to save duration fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions/${activeVersion.id}`, { 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 === activeVersion.id ? { ...v, duration: roundedDuration } : v ), }; }); }, [videoDuration, activeVersion?.id, activeVersion?.duration, propProjectId, videoId]); // Load watch progress when video is loaded (authenticated users only) const loadWatchProgress = useCallback(async (showPrompt = true) => { if (!video?.isAuthenticated || !activeVersionId) return; // Reset state setSavedProgress(null); setShowResumePrompt(false); try { // Use cache: 'no-store' to always fetch fresh data const res = await fetch(`/api/watch/${videoId}/progress`, { cache: 'no-store' }); if (res.ok) { const response = await res.json(); const progress = response.data?.progress || 0; const percentage = response.data?.percentage || 0; // Only show resume prompt if progress is between 5% and 95% if (showPrompt && percentage > 5 && percentage < 95) { setSavedProgress(progress); setShowResumePrompt(true); } } } catch (err) { console.error('Error loading watch progress:', err); } }, [video?.isAuthenticated, activeVersionId, videoId]); // Load progress on mount and when dependencies change useEffect(() => { loadWatchProgress(); }, [loadWatchProgress, progressFetchKey]); // Refetch progress when pathname changes (user navigates back to this page) useEffect(() => { if (lastPathnameRef.current !== pathname) { const previousPath = lastPathnameRef.current; lastPathnameRef.current = pathname; // If we navigated away and came back to this video page, refetch progress if (previousPath !== pathname) { setProgressFetchKey(k => k + 1); } } }, [pathname]); // Save watch progress periodically while playing (authenticated users only) useEffect(() => { if (!video?.isAuthenticated || !isReady || !activeVersionId) return; // Save progress every 5 seconds while playing progressSaveTimerRef.current = setInterval(() => { const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0; const playerDuration = playerRef.current?.getDuration?.() || 0; if (playerCurrentTime > 0 && Math.abs(playerCurrentTime - lastSavedProgressRef.current) >= 2) { // Save to API - use player duration directly fetch(`/api/watch/${videoId}/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ progress: playerCurrentTime, duration: playerDuration || videoDuration, versionId: activeVersionId, }), }).catch((err) => console.error('Error saving watch progress:', err)); lastSavedProgressRef.current = playerCurrentTime; } }, 5000); return () => { if (progressSaveTimerRef.current) { clearInterval(progressSaveTimerRef.current); } }; }, [video?.isAuthenticated, isReady, currentTime, videoDuration, activeVersionId, videoId]); const toggleFullscreen = useCallback(() => { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().then(() => { setIsFullscreenMode(true); setShowComments(false); }).catch((err) => { console.error('Fullscreen failed:', err); toast.error('Unable to enter fullscreen mode'); }); } else { document.exitFullscreen().then(() => { setIsFullscreenMode(false); setShowComments(true); }).catch((err) => { console.error('Exit fullscreen failed:', err); toast.error('Unable to exit fullscreen mode'); }); } }, []); useEffect(() => { const handleFullscreenChange = () => { const isCurrentlyFullscreen = !!document.fullscreenElement; setIsFullscreenMode(isCurrentlyFullscreen); if (isCurrentlyFullscreen) { setShowComments(false); } else { setShowComments(true); } }; document.addEventListener('fullscreenchange', handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); }, []); // Save progress when user leaves the page useEffect(() => { if (!video?.isAuthenticated) return; const saveProgressOnLeave = () => { // Get current time and duration directly from player instance const playerCurrentTime = playerRef.current?.getCurrentTime?.() || currentTime; const playerDuration = playerRef.current?.getDuration?.() || videoDuration; if (playerCurrentTime > 0 && navigator.sendBeacon) { // Use sendBeacon for reliable save on page unload const data = new Blob([JSON.stringify({ progress: playerCurrentTime, duration: playerDuration, versionId: activeVersionId, })], { type: 'application/json' }); navigator.sendBeacon(`/api/watch/${videoId}/progress`, data); } }; // Save when tab becomes hidden (user switches tabs, minimizes, etc.) const handleVisibilityChange = () => { // Get current time and duration directly from player instance const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0; const playerDuration = playerRef.current?.getDuration?.() || videoDuration; if (document.visibilityState === 'hidden' && playerCurrentTime > 0 && activeVersionId) { fetch(`/api/watch/${videoId}/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ progress: playerCurrentTime, duration: playerDuration, versionId: activeVersionId, }), }).catch((err) => console.error('Error saving watch progress on visibility change:', err)); } }; window.addEventListener('beforeunload', saveProgressOnLeave); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('beforeunload', saveProgressOnLeave); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId]); // Resume playback from saved position const handleResumeFromSaved = useCallback(() => { if (savedProgress !== null && 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 (playerRef.current?.getCurrentTime && !isDragging) { setCurrentTime(playerRef.current.getCurrentTime()); } }, 250); return () => clearInterval(interval); }, [isReady, isDragging]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { 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?.seekTo) { const newTime = Math.max(0, currentTime - 5); playerRef.current.seekTo(newTime, true); setCurrentTime(newTime); } break; case 'ArrowRight': e.preventDefault(); if (playerRef.current?.seekTo) { const newTime = Math.min(duration, currentTime + 5); 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); }, [isPlaying, currentTime, duration, isMuted, playbackSpeed, toggleFullscreen]); const handlePlayPause = useCallback(() => { if (!playerRef.current) return; if (isPlaying) { playerRef.current.pauseVideo(); } else { playerRef.current.playVideo(); } }, [isPlaying]); const handleSeekToTimestamp = useCallback((timestamp: number) => { setCurrentTime(timestamp); if (playerRef.current?.seekTo) { playerRef.current.seekTo(timestamp, true); } }, []); 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 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 && !commentText.trim()) return; if (!activeVersion) return; const tempId = `temp-${Date.now()}`; const optimisticComment: Comment = { id: tempId, content: voiceData ? commentText.trim() || null : commentText, timestamp: selectedTimestamp ?? currentTime, voiceUrl: voiceData?.url ?? null, voiceDuration: voiceData?.duration ?? null, isResolved: false, createdAt: new Date().toISOString(), author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, guestName: isGuest ? guestName : null, tag: availableTags.find(t => t.id === selectedTagId) || null, replies: [], }; setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: [...v.comments, optimisticComment] } : v ), }; }); setCommentText(''); setSelectedTimestamp(null); setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null); setAudioBlob(null); setIsSubmittingComment(true); isMutatingRef.current = true; try { const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: voiceData ? commentText.trim() || null : commentText, timestamp: selectedTimestamp ?? currentTime, ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(isGuest && guestName && { guestName }), ...(selectedTagId && { tagId: selectedTagId }), }), }); if (res.ok) { const response = await res.json(); const newComment = response.data; setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.map(c => c.id === tempId ? { ...newComment, replies: newComment.replies || [] } : { ...c, replies: c.replies || [] }) } : v ), }; }); } else { setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments.filter(c => c.id !== tempId) } : v ), }; }); toast.error('Failed to add comment'); } } catch (err) { 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); isMutatingRef.current = false; } }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]); 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'); 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]); 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); } }; }, []); const handleResolveComment = useCallback( async (commentId: string, currentlyResolved: boolean) => { 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 (err) { 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] ); const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => { if (!voiceData && !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 ? replyText.trim() || null : replyText, voiceUrl: voiceData?.url ?? null, voiceDuration: voiceData?.duration ?? null, createdAt: new Date().toISOString(), author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, guestName: isGuest ? guestName : null, 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); setIsSubmittingReply(true); isMutatingRef.current = true; try { const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: voiceData ? replyText.trim() || null : replyText, timestamp: parentComment?.timestamp ?? currentTime, parentId, ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(isGuest && guestName && { guestName }), }), }); 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 (err) { 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); isMutatingRef.current = false; } }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]); 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'); 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]); const handleEditComment = useCallback(async (commentId: string) => { if (!editText.trim()) return; setIsSubmittingEdit(true); isMutatingRef.current = true; try { const body: Record = { content: editText }; if (editTagId !== undefined) body.tagId = editTagId; 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 }; return { ...c, replies: (c.replies || []).map((r) => r.id === commentId ? { ...r, content: editText.trim() } : r ), }; }), } : v ), }; }); setEditingCommentId(null); setEditText(''); setEditTagId(null); } } catch (err) { console.error('Failed to edit comment:', err); } finally { setIsSubmittingEdit(false); isMutatingRef.current = false; } }, [editText, editTagId, activeVersionId, availableTags]); const handleDeleteComment = useCallback(async (commentId: string) => { setDeletingCommentId(commentId); isMutatingRef.current = true; const previousVideo = video; setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: v.comments .filter((c) => c.id !== commentId) .map((c) => ({ ...c, replies: c.replies.filter((r) => r.id !== commentId), })), } : v ), }; }); try { const res = await fetch(`/api/comments/${commentId}`, { method: 'DELETE' }); if (!res.ok) { setVideo(previousVideo); } } catch (err) { console.error('Failed to delete comment:', err); setVideo(previousVideo); } finally { setDeletingCommentId(null); isMutatingRef.current = false; } }, [activeVersionId, video]); // Comment polling with Page Visibility API to pause when tab is hidden useEffect(() => { if (!activeVersion) return; let intervalId: ReturnType | null = null; let isPageVisible = true; const poll = async () => { try { if (isMutatingRef.current || !isPageVisible) return; const res = await fetch(apiBasePath, { cache: 'no-store' }); if (res.ok) { const data = await res.json(); if (!isMutatingRef.current) { setVideo(data.data); } } } catch { /* silent */ } }; // Start polling intervalId = setInterval(poll, 10000); // Handle page visibility change const handleVisibilityChange = () => { isPageVisible = document.visibilityState === 'visible'; }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { if (intervalId) clearInterval(intervalId); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [activeVersion, apiBasePath]); const handleNewVersionUrlChange = (url: string) => { setNewVersionUrl(url); setNewVersionUrlError(''); if (!url.trim()) { setNewVersionSource(null); return; } const source = parseVideoUrl(url); if (source) { setNewVersionSource(source); } else { setNewVersionSource(null); if (url.length > 10) setNewVersionUrlError('Unsupported URL'); } }; const handleCreateVersion = async () => { if (!newVersionSource || !propProjectId) return; setIsCreatingVersion(true); try { const meta = await fetchVideoMetadata(newVersionSource); const thumbnailUrl = getThumbnailUrl(newVersionSource, 'large'); const res = await fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ videoUrl: newVersionSource.originalUrl, providerId: newVersionSource.providerId, providerVideoId: newVersionSource.videoId, versionLabel: newVersionLabel.trim() || null, thumbnailUrl, duration: meta?.duration || null, setActive: true, }), }); if (res.ok) { 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); } } catch (err) { console.error('Failed to create version:', err); } finally { setIsCreatingVersion(false); } }; // Version deletion const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false); const [versionToDelete, setVersionToDelete] = useState(null); const [isDeletingVersion, setIsDeletingVersion] = useState(false); const handleDeleteVersion = async () => { if (!versionToDelete || !propProjectId) return; setIsDeletingVersion(true); try { const res = await fetch( `/api/projects/${propProjectId}/videos/${videoId}/versions/${versionToDelete}`, { method: 'DELETE' } ); if (res.ok) { setVideo((prev) => { if (!prev) return prev; const remaining = prev.versions.filter((v) => v.id !== versionToDelete); return { ...prev, versions: remaining }; }); // If deleted version was active, switch to the first remaining if (activeVersionId === versionToDelete && video) { const remaining = video.versions.filter((v) => v.id !== versionToDelete); if (remaining.length > 0) setActiveVersionId(remaining[0].id); } setShowDeleteVersionDialog(false); setVersionToDelete(null); } else { const data = await res.json(); toast.error(data.error || 'Failed to delete version'); } } catch { toast.error('Failed to delete version'); } finally { setIsDeletingVersion(false); } }; const containerHeight = 'h-screen'; const backHref = mode === 'dashboard' ? `/projects/${propProjectId}` : (video?.projectId ? `/projects/${video.projectId}` : '/'); if (loading) { return (
{mode === 'dashboard' && }
{Array.from({ length: 5 }).map((_, i) => (
))}
); } if (error || !video || !activeVersion) { return (

{error || 'Video not found'}

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

Welcome to OpenFrame

Enter your name to view and comment on this video

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

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

); } return (
isDragging && handleTimelineMouseUp()} >
Back
{video.title} • {video.project.name}
{video.versions.map((version) => ( handleVersionSelect(version.id)} > v{version.versionNumber} {version.versionLabel || `Version ${version.versionNumber}`} {version._count.comments} comments ))} {mode === 'dashboard' && video.versions.length > 1 && ( <> { setVersionToDelete(activeVersionId); setShowDeleteVersionDialog(true); }} > Delete Current Version )} {/* Version Delete Confirmation */} Delete this version? This will permanently delete this version and all its comments. This cannot be undone. Cancel {isDeletingVersion && } Delete Version {mode === 'dashboard' && ( <> Add New Version Upload a new version of this video. The new version will become active.
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} />
{video.versions.length >= 2 && ( )} )}