'use client'; import { useState, useRef, useCallback, useEffect } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { ArrowLeft, Play, Pause, Volume2, VolumeX, MessageSquare, Mic, Send, Clock, CheckCircle2, Circle, ChevronDown, MoreVertical, SkipBack, SkipForward, Loader2, Reply, Pencil, Trash2, X, ArrowUpRight, User, Tag, } 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; 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; visibility: string; }; versions: (Version & { comments: Comment[] })[]; isAuthenticated: boolean; canComment: boolean; } function formatTime(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); 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 default function WatchPage() { const params = useParams(); const videoId = params.videoId as string; const iframeRef = useRef(null); const playerRef = useRef(null); const timelineRef = useRef(null); 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 [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [isMuted, setIsMuted] = useState(false); const [isDragging, setIsDragging] = useState(false); const [playbackSpeed, setPlaybackSpeed] = useState(1); 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); // Reply/Edit/Delete state 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 [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [deletingCommentId, setDeletingCommentId] = useState(null); const [guestName, setGuestName] = useState(''); const [guestNameConfirmed, setGuestNameConfirmed] = useState(false); // Tag state const [availableTags, setAvailableTags] = useState([]); const [selectedTagId, setSelectedTagId] = useState(null); // Restore guest name from localStorage useEffect(() => { const saved = localStorage.getItem('openframe_guest_name'); if (saved) { setGuestName(saved); setGuestNameConfirmed(true); } }, []); const isGuest = video ? !video.isAuthenticated : false; // Fetch video data useEffect(() => { async function fetchVideo() { try { const res = await fetch(`/api/watch/${videoId}`); if (!res.ok) { setError('Video not found or access denied'); setLoading(false); return; } const data = await res.json(); setVideo(data); const active = data.versions.find((v: Version) => v.isActive) || data.versions[0]; if (active) setActiveVersionId(active.id); } catch { setError('Failed to load video'); } finally { setLoading(false); } } fetchVideo(); }, [videoId]); const activeVersion = video?.versions.find((v) => v.id === activeVersionId); const comments = activeVersion?.comments || []; const filteredComments = comments.filter((c) => showResolved || !c.isResolved); const duration = activeVersion?.duration || 300; // Fetch tags for the project useEffect(() => { const projectId = video?.projectId; if (!projectId) return; async function fetchTags() { try { const res = await fetch(`/api/projects/${projectId}/tags`); if (res.ok) { const tags = await res.json(); setAvailableTags(tags); // Auto-select first tag (Feedback) as default if (tags.length > 0 && !selectedTagId) { setSelectedTagId(tags[0].id); } } } catch { // Silent fail - tags are optional } } fetchTags(); }, [video?.projectId]); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ignore if user is typing in an input/textarea const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } switch (e.code) { case 'Space': 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': // < key (Shift+,) 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': // > key (Shift+.) 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 'KeyK': e.preventDefault(); if (playerRef.current) { if (isPlaying) { playerRef.current.pauseVideo(); } else { playerRef.current.playVideo(); } } break; case 'KeyL': e.preventDefault(); if (playerRef.current?.seekTo) { const newTime = Math.min(duration, currentTime + 10); playerRef.current.seekTo(newTime, true); setCurrentTime(newTime); } break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isPlaying, currentTime, duration, isMuted, playbackSpeed]); // Load YouTube iframe API useEffect(() => { if (!activeVersion || activeVersion.providerId !== 'youtube') return; if (!window.YT) { const tag = document.createElement('script'); tag.src = 'https://www.youtube.com/iframe_api'; const firstScriptTag = document.getElementsByTagName('script')[0]; firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag); } const initPlayer = () => { if (!iframeRef.current) return; playerRef.current = new YT.Player(iframeRef.current, { events: { onReady: () => setIsReady(true), onStateChange: (event: YT.OnStateChangeEvent) => { setIsPlaying(event.data === YT.PlayerState.PLAYING); }, }, }); }; if (window.YT?.Player) { initPlayer(); } else { window.onYouTubeIframeAPIReady = initPlayer; } return () => { window.onYouTubeIframeAPIReady = undefined; }; }, [activeVersion]); // Update current time periodically useEffect(() => { if (!isReady || !playerRef.current) return; const interval = setInterval(() => { if (playerRef.current?.getCurrentTime && !isDragging) { setCurrentTime(playerRef.current.getCurrentTime()); } }, 100); return () => clearInterval(interval); }, [isReady, isDragging]); 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 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)); const newTime = percentage * duration; setCurrentTime(newTime); }, [isDragging, duration] ); const handleTimelineMouseUp = useCallback(() => { if (isDragging) { handleSeekToTimestamp(currentTime); setIsDragging(false); } }, [isDragging, currentTime, handleSeekToTimestamp]); 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 handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => { if (!voiceData && !commentText.trim()) return; if (!activeVersion) return; setIsSubmittingComment(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 newComment = await res.json(); setVideo((prev) => { if (!prev) return prev; return { ...prev, versions: prev.versions.map((v) => v.id === activeVersionId ? { ...v, comments: [...v.comments, { ...newComment, replies: newComment.replies || [] }] } : v ), }; }); setCommentText(''); setSelectedTimestamp(null); setSelectedTagId(null); } } catch (err) { console.error('Failed to add comment:', err); } finally { setIsSubmittingComment(false); } }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId]); // Voice recording handlers 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 { url } = await uploadRes.json(); 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]); // Voice playback 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; }); }, []); // Cleanup audio on unmount 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) => { 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: !c.isResolved } : c ), } : v ), }; }); } } catch (err) { console.error('Failed to resolve comment:', err); } }, [activeVersionId] ); // Reply to a comment const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => { if (!voiceData && !replyText.trim()) return; if (!activeVersion) return; setIsSubmittingReply(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: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime, parentId, ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(isGuest && guestName && { guestName }), }), }); if (res.ok) { const newReply = await res.json(); 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, newReply] } : c ), } : v ), }; }); setReplyText(''); setReplyingTo(null); setReplyAudioBlob(null); setReplyRecordingTime(0); } } catch (err) { console.error('Failed to reply:', err); } finally { setIsSubmittingReply(false); } }, [replyText, activeVersion, activeVersionId, comments, currentTime]); // Voice recording for replies 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 { url } = await uploadRes.json(); await handleReplyComment(parentId, { url, duration: replyRecordingTime }); } catch (err) { console.error('Failed to submit voice reply:', err); } finally { setIsUploadingReplyAudio(false); } }, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]); // Edit a comment const handleEditComment = useCallback(async (commentId: string) => { if (!editText.trim()) return; setIsSubmittingEdit(true); try { const res = await fetch(`/api/comments/${commentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: editText }), }); 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) => { if (c.id === commentId) return { ...c, content: editText.trim() }; return { ...c, replies: c.replies.map((r) => r.id === commentId ? { ...r, content: editText.trim() } : r ), }; }), } : v ), }; }); setEditingCommentId(null); setEditText(''); } } catch (err) { console.error('Failed to edit comment:', err); } finally { setIsSubmittingEdit(false); } }, [editText, activeVersionId]); // Delete a comment const handleDeleteComment = useCallback(async (commentId: string) => { setDeletingCommentId(commentId); try { const res = await fetch(`/api/comments/${commentId}`, { method: 'DELETE' }); if (res.ok) { 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 ), }; }); } } catch (err) { console.error('Failed to delete comment:', err); } finally { setDeletingCommentId(null); } }, [activeVersionId]); // Poll for new comments every 10 seconds useEffect(() => { if (!video) return; const interval = setInterval(async () => { try { const res = await fetch(`/api/watch/${videoId}`); if (res.ok) { const data = await res.json(); setVideo(data); } } catch { /* silent */ } }, 10000); return () => clearInterval(interval); }, [video, videoId]); const getEmbedUrl = (version: Version) => { if (version.providerId === 'youtube') { return `https://www.youtube.com/embed/${version.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`; } if (version.providerId === 'vimeo') { return `https://player.vimeo.com/video/${version.videoId}`; } return version.originalUrl; }; if (loading) { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); } if (error || !video || !activeVersion) { return (

{error || 'Video not found'}

); } // Guest name gate — prompt guests to enter their name before viewing if (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

); } const embedUrl = getEmbedUrl(activeVersion); return (
isDragging && handleTimelineMouseUp()} >
{/* Video Area */}
{/* Compact Header Bar */}
Back
{video.title} • {video.project.name}
{/* Version Selector */} {video.versions.map((version) => ( setActiveVersionId(version.id)} > v{version.versionNumber} {version.versionLabel || `Version ${version.versionNumber}`} {version._count.comments} comments ))}
{/* Video Player - Maximized */}