'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, 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Separator } from '@/components/ui/separator'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, 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 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')}`; } 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 [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); const [guestName, setGuestName] = useState(''); // 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; // 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 () => { 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: 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 (!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 (
); } 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 */} {video.versions.map((version) => ( setActiveVersionId(version.id)} > v{version.versionNumber} {version.versionLabel || `Version ${version.versionNumber}`} {version._count.comments} comments ))}
{/* Video Player - Maximized */}