'use client'; import { useState, useRef, useCallback, useEffect } from 'react'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import { ArrowLeft, Play, Pause, Volume2, VolumeX, SkipBack, SkipForward, Gauge, MessageSquare, Mic, Send, Clock, CheckCircle2, Circle, ChevronDown, MoreVertical, Plus, Loader2, Link as LinkIcon, AlertCircle, GitCompareArrows, Reply, Pencil, Trash2, X, ArrowUpRight, } 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, 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 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; replies: { id: string; content: string | null; createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; }[]; } interface VideoData { id: string; title: string; description: string | null; projectId: string; project: { name: string; ownerId: string; members: { role: string }[]; }; versions: (Version & { comments: Comment[] })[]; } 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 VideoPage() { const params = useParams(); const router = useRouter(); const projectId = params.projectId as string; 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 [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 [commentText, setCommentText] = useState(''); const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [isRecording, setIsRecording] = useState(false); 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 [editingCommentId, setEditingCommentId] = useState(null); const [editText, setEditText] = useState(''); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [deletingCommentId, setDeletingCommentId] = useState(null); // New version dialog 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); // Fetch video data useEffect(() => { async function fetchVideo() { try { const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`); if (!res.ok) { setError('Failed to load video'); 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(); }, [projectId, videoId]); const activeVersion = video?.versions.find((v) => v.id === activeVersionId); const comments = activeVersion?.comments || []; const filteredComments = comments.filter((c) => showResolved || !c.isResolved); const duration = videoDuration || activeVersion?.duration || 0; // Load YouTube iframe API script once useEffect(() => { if (window.YT) 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); }, []); // Initialize / reinitialize YouTube player when version changes useEffect(() => { if (!activeVersion || activeVersion.providerId !== 'youtube') return; // Reset state for new version setIsReady(false); setCurrentTime(0); setVideoDuration(0); setIsPlaying(false); setPlaybackSpeed(1); // Destroy previous player if it exists 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); // Update duration when playback starts (more reliable) if (event.data === YT.PlayerState.PLAYING) { const dur = event.target.getDuration(); if (dur > 0) setVideoDuration(dur); } }, }, }); }; // Wait a tick for the iframe to update its src before binding const timeout = setTimeout(() => { if (window.YT?.Player) { initPlayer(); } else { window.onYouTubeIframeAPIReady = initPlayer; } }, 100); return () => { clearTimeout(timeout); window.onYouTubeIframeAPIReady = undefined; }; }, [activeVersionId]); // Update current time periodically useEffect(() => { if (!isReady || !playerRef.current) return; const interval = setInterval(() => { if (playerRef.current?.getCurrentTime && !isDragging) { setCurrentTime(playerRef.current.getCurrentTime()); } }, 250); 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 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 () => { if (!commentText.trim() || !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: commentText, timestamp: selectedTimestamp ?? currentTime, }), }); 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: [] }] } : v ), }; }); setCommentText(''); setSelectedTimestamp(null); } } catch (err) { console.error('Failed to add comment:', err); } finally { setIsSubmittingComment(false); } }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId]); 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) => { if (!replyText.trim() || !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: replyText, timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime, parentId, }), }); 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); } } catch (err) { console.error('Failed to reply:', err); } finally { setIsSubmittingReply(false); } }, [replyText, activeVersion, activeVersionId, comments, currentTime]); // 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 (!activeVersion) return; const interval = setInterval(async () => { try { const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`); if (res.ok) { const data = await res.json(); setVideo(data); } } catch { /* silent */ } }, 10000); return () => clearInterval(interval); }, [activeVersion, projectId, videoId]); // New version URL handler 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) return; setIsCreatingVersion(true); try { const meta = await fetchVideoMetadata(newVersionSource); const thumbnailUrl = getThumbnailUrl(newVersionSource, 'large'); const res = await fetch(`/api/projects/${projectId}/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 videoRes = await fetch(`/api/projects/${projectId}/videos/${videoId}`); if (videoRes.ok) { const data = await videoRes.json(); setVideo(data); const active = data.versions.find((v: Version) => v.isActive) || data.versions[0]; if (active) setActiveVersionId(active.id); } setShowVersionDialog(false); setNewVersionUrl(''); setNewVersionLabel(''); setNewVersionSource(null); } } catch (err) { console.error('Failed to create version:', err); } finally { setIsCreatingVersion(false); } }; 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 (
); } if (error || !video || !activeVersion) { return (

{error || 'Video not found'}

); } const embedUrl = getEmbedUrl(activeVersion); return (
isDragging && handleTimelineMouseUp()} >
{/* Video Area */}
{/* Compact Header Bar */}
Back
{video.title} • {video.project.name}
{/* Version Selector + Add Version */}
{video.versions.map((version) => ( setActiveVersionId(version.id)} > v{version.versionNumber} {version.versionLabel || `Version ${version.versionNumber}`} {version._count.comments} comments ))} {video.versions.length >= 2 && ( )} 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 Player - click to play/pause, YouTube controls hidden */}