'use client'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import Hls from 'hls.js'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { ArrowLeft, MessageSquare, ChevronDown, Loader2, GitCompareArrows, Clock, X, Play, Pause, Volume2, VolumeX, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { isPlayableVideoUrl, resolveR2PlaybackUrl } from '@/lib/video-upload-validation'; 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 PlayerAdapter { playVideo: () => void; pauseVideo: () => void; seekTo: (time: number, allowSeekAhead?: boolean) => void; mute: () => void; unMute: () => void; isMuted: () => boolean; getCurrentTime: () => number; getDuration: () => number; getPlayerState: () => number; setPlaybackRate?: (rate: number) => void; destroy: () => void; } interface Comment { id: string; content: string | null; timestamp: number; voiceUrl: string | null; voiceDuration: number | null; imageUrl: string | null; annotationData: string | null; isResolved: boolean; createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; tag: { id: string; name: string; color: string } | null; } interface VideoData { id: string; title: string; description: string | null; projectId: string; project: { name: string; }; versions: Version[]; } function formatTime(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } // Panels drifting past this from the source player read as out-of-sync playback. const MAX_PANEL_DRIFT_SECONDS = 0.35; // Minimum gap between two corrective seeks of the same panel. const RESYNC_COOLDOWN_MS = 4000; const isSafeUrl = (url: string) => { try { const parsed = new URL(url); return parsed.protocol === 'http:' || parsed.protocol === 'https:'; } catch { return false; } }; export default function CompareVersionsPageClient({ projectId, videoId, }: { projectId: string; videoId: string; }) { const searchParams = useSearchParams(); const [video, setVideo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [isApiLoaded, setIsApiLoaded] = useState(false); // Panel version IDs const [panelVersionIds, setPanelVersionIds] = useState([]); // Shared playback state const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [isDragging, setIsDragging] = useState(false); const [cursorIdle, setCursorIdle] = useState(false); const cursorIdleTimerRef = useRef | null>(null); const timelineRef = useRef(null); // Map of versionId -> YT.Player or Custom Adapter const playersRef = useRef>(new Map()); const rafRef = useRef(null); // Comments state per panel const [openCommentsPanel, setOpenCommentsPanel] = useState(null); const [commentsCache, setCommentsCache] = useState>(new Map()); const [commentsLoading, setCommentsLoading] = useState(null); // Per-panel mute state const [mutedPanels, setMutedPanels] = useState>(new Set()); // Refs for fast-changing playback values — avoids React re-renders on every frame const currentTimeRef = useRef(0); const durationRef = useRef(0); const lastCommitRef = useRef(0); const lastSyncRef = useRef(0); const resyncCooldownRef = useRef(new WeakMap()); // Direct DOM refs for progress bar / playhead / timecode — updated in the RAF loop const progressBarRef = useRef(null); const playheadRef = useRef(null); const timecodeRef = useRef(null); // Load YouTube IFrame API useEffect(() => { if (typeof window === 'undefined') return; 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); }; }, []); // Fetch video data useEffect(() => { async function fetchVideo() { try { const res = await fetch( `/api/projects/${projectId}/videos/${videoId}?includeComments=false` ); if (!res.ok) { setError('Failed to load video'); setLoading(false); return; } const response = await res.json(); const data = response.data; setVideo(data); const versionsParam = searchParams.get('versions'); if (versionsParam) { const ids = versionsParam .split(',') .filter((id) => data.versions.some((v: Version) => v.id === id)); if (ids.length >= 2) { setPanelVersionIds(ids); } else { const sorted = [...data.versions].sort( (a: Version, b: Version) => a.versionNumber - b.versionNumber ); setPanelVersionIds([sorted[sorted.length - 2].id, sorted[sorted.length - 1].id]); } } else if (data.versions.length >= 2) { const sorted = [...data.versions].sort( (a: Version, b: Version) => a.versionNumber - b.versionNumber ); setPanelVersionIds([sorted[sorted.length - 2].id, sorted[sorted.length - 1].id]); } else if (data.versions.length === 1) { setPanelVersionIds([data.versions[0].id]); } } catch { setError('Failed to load video'); } finally { setLoading(false); } } fetchVideo(); }, [projectId, videoId, searchParams]); // Auto-fetch comments for all panels so timeline markers appear immediately useEffect(() => { if (panelVersionIds.length === 0) return; panelVersionIds.forEach(async (versionId) => { if (commentsCache.has(versionId)) return; try { const res = await fetch(`/api/versions/${versionId}/comments`); const json = await res.json(); const data = json.data; const commentsList = Array.isArray(data) ? data : (data?.comments ?? []); setCommentsCache((prev) => new Map(prev).set(versionId, commentsList)); } catch { setCommentsCache((prev) => new Map(prev).set(versionId, [])); } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [panelVersionIds]); // RAF loop: update refs + DOM every frame, throttle React state commits to ~250ms useEffect(() => { const tick = (timestamp: number) => { if (!isDragging) { const players = Array.from(playersRef.current.values()); const sourcePlayer = players[0]; if (sourcePlayer) { try { const t = sourcePlayer.getCurrentTime(); const d = sourcePlayer.getDuration(); // PLAYING is 1 in the YouTube API; the numeric fallback keeps // state detection working when the YT script never loads // (bunny/r2-only comparisons, ad blockers). const playing = sourcePlayer.getPlayerState() === (window.YT?.PlayerState?.PLAYING ?? 1); // Update refs immediately — zero React overhead if (t !== undefined) currentTimeRef.current = t; if (d > 0) durationRef.current = d; // Directly mutate DOM for smooth timeline visuals without re-renders const dur = durationRef.current; if (t !== undefined && dur > 0) { const pct = (t / dur) * 100; if (progressBarRef.current) progressBarRef.current.style.width = `${pct}%`; if (playheadRef.current) playheadRef.current.style.left = `calc(${pct}% - 2px)`; if (timecodeRef.current) { timecodeRef.current.textContent = `${formatTime(t)} / ${formatTime(dur)}`; } } // Re-sync followers that drift from the source player — providers // buffer at different speeds and drift past ~350ms reads as // out-of-sync playback. The per-player cooldown keeps a follower // that simply cannot keep up (slow network, HLS rebuffering) from // being seeked every second, which would stutter rather than correct. if (playing && t !== undefined && timestamp - lastSyncRef.current >= 1000) { lastSyncRef.current = timestamp; const cooldowns = resyncCooldownRef.current; for (let i = 1; i < players.length; i += 1) { const follower = players[i]; if (timestamp - (cooldowns.get(follower) ?? 0) < RESYNC_COOLDOWN_MS) continue; try { if (Math.abs(follower.getCurrentTime() - t) > MAX_PANEL_DRIFT_SECONDS) { cooldowns.set(follower, timestamp); follower.seekTo(t, true); } } catch { // Player not ready } } } // Throttle React state commits to ~4 updates/sec if (timestamp - lastCommitRef.current >= 250) { lastCommitRef.current = timestamp; if (t !== undefined) setCurrentTime(t); if (d > 0) setDuration(d); setIsPlaying(playing); } } catch { // Player not ready } } } rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, [isDragging]); // Register/unregister players const registerPlayer = useCallback((versionId: string, player: YT.Player | PlayerAdapter) => { playersRef.current.set(versionId, player); }, []); const unregisterPlayer = useCallback((versionId: string) => { playersRef.current.delete(versionId); }, []); // ===================== // Shared playback controls — always synced // ===================== const handlePlayPause = useCallback(() => { const players = Array.from(playersRef.current.values()); if (players.length === 0) return; try { const firstPlayer = players[0]; const state = firstPlayer.getPlayerState(); const playing = state === (window.YT?.PlayerState?.PLAYING ?? 1); if (playing) { players.forEach((p) => { try { p.pauseVideo(); } catch { /* */ } }); setIsPlaying(false); } else { const t = firstPlayer.getCurrentTime(); players.forEach((p) => { try { p.seekTo(t, true); p.playVideo(); } catch { /* */ } }); setIsPlaying(true); } } catch { // Player not ready } }, []); const handleSeek = useCallback((time: number) => { const players = Array.from(playersRef.current.values()); players.forEach((p) => { try { p.seekTo(time, true); } catch { /* */ } }); setCurrentTime(time); }, []); const handleTimelineMouseDown = useCallback( (e: React.MouseEvent) => { if (!timelineRef.current || durationRef.current <= 0) return; setIsDragging(true); const rect = timelineRef.current.getBoundingClientRect(); const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const time = fraction * durationRef.current; currentTimeRef.current = time; setCurrentTime(time); handleSeek(time); }, [handleSeek] ); const handleTimelineMouseMove = useCallback( (e: React.MouseEvent) => { if (!isDragging || !timelineRef.current || durationRef.current <= 0) return; const rect = timelineRef.current.getBoundingClientRect(); const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const time = fraction * durationRef.current; currentTimeRef.current = time; setCurrentTime(time); // Keep DOM in sync while the RAF loop is paused during drag const pct = fraction * 100; if (progressBarRef.current) progressBarRef.current.style.width = `${pct}%`; if (playheadRef.current) playheadRef.current.style.left = `calc(${pct}% - 2px)`; if (timecodeRef.current) { timecodeRef.current.textContent = `${formatTime(time)} / ${formatTime(durationRef.current)}`; } }, [isDragging] ); const handleTimelineMouseUp = useCallback(() => { if (!isDragging) return; setIsDragging(false); handleSeek(currentTimeRef.current); }, [isDragging, handleSeek]); const handleVideoMouseMove = useCallback(() => { setCursorIdle(false); if (cursorIdleTimerRef.current) { clearTimeout(cursorIdleTimerRef.current); } if (isPlaying) { cursorIdleTimerRef.current = setTimeout(() => { setCursorIdle(true); }, 1000); } }, [isPlaying]); const handleVideoMouseLeave = useCallback(() => { if (cursorIdleTimerRef.current) { clearTimeout(cursorIdleTimerRef.current); } setCursorIdle(false); }, []); useEffect(() => { return () => { if (cursorIdleTimerRef.current) { clearTimeout(cursorIdleTimerRef.current); } }; }, []); useEffect(() => { if (cursorIdleTimerRef.current) { clearTimeout(cursorIdleTimerRef.current); cursorIdleTimerRef.current = null; } if (!isPlaying) { setCursorIdle(false); return; } cursorIdleTimerRef.current = setTimeout(() => { setCursorIdle(true); }, 1000); return () => { if (cursorIdleTimerRef.current) { clearTimeout(cursorIdleTimerRef.current); cursorIdleTimerRef.current = null; } }; }, [isPlaying]); // Keyboard shortcuts (matching video page) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; const players = Array.from(playersRef.current.values()); if (players.length === 0) return; switch (e.code) { case 'Space': case 'KeyK': e.preventDefault(); handlePlayPause(); break; case 'ArrowLeft': e.preventDefault(); handleSeek(Math.max(0, currentTimeRef.current - 5)); break; case 'ArrowRight': e.preventDefault(); handleSeek(Math.min(durationRef.current, currentTimeRef.current + 5)); break; case 'KeyJ': e.preventDefault(); handleSeek(Math.max(0, currentTimeRef.current - 10)); break; case 'KeyL': e.preventDefault(); handleSeek(Math.min(durationRef.current, currentTimeRef.current + 10)); break; case 'KeyM': e.preventDefault(); players.forEach((p) => { try { if (p.isMuted?.()) { p.unMute?.(); } else { p.mute?.(); } } catch { /* */ } }); break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handlePlayPause, handleSeek]); // Fetch comments for a version const toggleComments = useCallback( async (versionId: string) => { if (openCommentsPanel === versionId) { setOpenCommentsPanel(null); return; } setOpenCommentsPanel(versionId); if (!commentsCache.has(versionId)) { setCommentsLoading(versionId); try { const res = await fetch(`/api/versions/${versionId}/comments`); const json = await res.json(); const data = json.data; const commentsList = Array.isArray(data) ? data : (data?.comments ?? []); setCommentsCache((prev) => new Map(prev).set(versionId, commentsList)); } catch { setCommentsCache((prev) => new Map(prev).set(versionId, [])); } finally { setCommentsLoading(null); } } }, [openCommentsPanel, commentsCache] ); const handleChangeVersion = useCallback((panelIndex: number, newVersionId: string) => { setPanelVersionIds((prev) => { const next = [...prev]; const oldId = next[panelIndex]; const oldPlayer = playersRef.current.get(oldId); if (oldPlayer) { try { oldPlayer.destroy(); } catch { /* */ } playersRef.current.delete(oldId); } next[panelIndex] = newVersionId; return next; }); setOpenCommentsPanel(null); }, []); // Collect all comments from all visible panels for timeline markers const allTimelineComments = panelVersionIds.flatMap((vid) => { const comments = commentsCache.get(vid) || []; const version = video?.versions.find((v) => v.id === vid); return comments.map((c) => ({ ...c, versionNumber: version?.versionNumber ?? 0, })); }); const usedVersionIds = new Set(panelVersionIds); if (loading) { return (
{Array.from({ length: 2 }).map((_, i) => (
))}
); } if (error || !video || video.versions.length < 2) { return (

{error || 'Need at least 2 versions to compare'}

); } return (
isDragging && handleTimelineMouseUp()} > {/* Header */}
Back
Compare Versions • {video.title}
{/* Video panels */}
{panelVersionIds.map((versionId, index) => { const version = video.versions.find((v) => v.id === versionId); if (!version) return null; const panelComments = commentsCache.get(versionId) || []; const isCommentsOpen = openCommentsPanel === versionId; const isLoadingComments = commentsLoading === versionId; return (
{/* Panel header */}
{video.versions.map((v) => ( handleChangeVersion(index, v.id)} disabled={usedVersionIds.has(v.id) && v.id !== version.id} > v{v.versionNumber} {v.versionLabel || `Version ${v.versionNumber}`} ))}
{/* Video embed with click-to-play overlay */}
{version.providerId === 'youtube' ? ( ) : version.providerId === 'bunny' ? ( ) : version.providerId === 'r2' ? ( ) : isSafeUrl(version.originalUrl) ? (