feat: Implement multi-panel video comparison with shared playback controls, timeline comments, and YouTube API integration.

This commit is contained in:
Yusuf İpek
2026-02-22 07:56:27 +03:00
parent 87d6c0ab0a
commit 5547346082
3 changed files with 703 additions and 153 deletions
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link';
import { useParams, useSearchParams } from 'next/navigation';
import {
@@ -9,11 +9,18 @@ import {
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,
@@ -36,6 +43,21 @@ interface Version {
_count: { comments: number };
}
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;
@@ -47,11 +69,10 @@ interface VideoData {
versions: Version[];
}
function getEmbedUrl(version: Version) {
if (version.providerId === 'youtube') {
return `https://www.youtube.com/embed/${version.videoId}?enablejsapi=1&rel=0&modestbranding=1`;
}
return version.originalUrl;
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 CompareVersionsPage() {
@@ -63,10 +84,49 @@ export default function CompareVersionsPage() {
const [video, setVideo] = useState<VideoData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isApiLoaded, setIsApiLoaded] = useState(false);
const [leftVersionId, setLeftVersionId] = useState<string | null>(null);
const [rightVersionId, setRightVersionId] = useState<string | null>(null);
// Panel version IDs
const [panelVersionIds, setPanelVersionIds] = useState<string[]>([]);
// Shared playback state
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const timelineRef = useRef<HTMLDivElement>(null);
// Map of versionId -> YT.Player
const playersRef = useRef<Map<string, YT.Player>>(new Map());
const rafRef = useRef<number | null>(null);
// Comments state per panel
const [openCommentsPanel, setOpenCommentsPanel] = useState<string | null>(null);
const [commentsCache, setCommentsCache] = useState<Map<string, Comment[]>>(new Map());
const [commentsLoading, setCommentsLoading] = useState<string | null>(null);
// Per-panel mute state
const [mutedPanels, setMutedPanels] = useState<Set<string>>(new Set());
// 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 {
@@ -80,28 +140,26 @@ export default function CompareVersionsPage() {
const data = response.data;
setVideo(data);
// Set initial versions from query params or defaults
const leftParam = searchParams.get('left');
const rightParam = searchParams.get('right');
if (data.versions.length >= 2) {
// Sort versions ascending for comparison (older on left, newer on right)
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
);
setLeftVersionId(
leftParam && sorted.find((v: Version) => v.id === leftParam)
? leftParam
: sorted[sorted.length - 2].id
);
setRightVersionId(
rightParam && sorted.find((v: Version) => v.id === rightParam)
? rightParam
: sorted[sorted.length - 1].id
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) {
setLeftVersionId(data.versions[0].id);
setRightVersionId(data.versions[0].id);
setPanelVersionIds([data.versions[0].id]);
}
} catch {
setError('Failed to load video');
@@ -112,8 +170,216 @@ export default function CompareVersionsPage() {
fetchVideo();
}, [projectId, videoId, searchParams]);
const leftVersion = video?.versions.find((v) => v.id === leftVersionId);
const rightVersion = video?.versions.find((v) => v.id === rightVersionId);
// 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 polling for time updates
useEffect(() => {
const tick = () => {
if (!isDragging) {
const players = Array.from(playersRef.current.values());
const sourcePlayer = players[0];
if (sourcePlayer) {
try {
const t = sourcePlayer.getCurrentTime();
const d = sourcePlayer.getDuration();
if (t !== undefined) setCurrentTime(t);
if (d > 0) setDuration(d);
// Check play state from player
const state = sourcePlayer.getPlayerState();
setIsPlaying(state === window.YT?.PlayerState?.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) => {
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;
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 || duration <= 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 * duration;
setCurrentTime(time);
handleSeek(time);
}, [duration, handleSeek]);
const handleTimelineMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging || !timelineRef.current || duration <= 0) return;
const rect = timelineRef.current.getBoundingClientRect();
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const time = fraction * duration;
setCurrentTime(time);
}, [isDragging, duration]);
const handleTimelineMouseUp = useCallback(() => {
if (!isDragging) return;
setIsDragging(false);
handleSeek(currentTime);
}, [isDragging, currentTime, handleSeek]);
// 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, currentTime - 5));
break;
case 'ArrowRight':
e.preventDefault();
handleSeek(Math.min(duration, currentTime + 5));
break;
case 'KeyJ':
e.preventDefault();
handleSeek(Math.max(0, currentTime - 10));
break;
case 'KeyL':
e.preventDefault();
handleSeek(Math.min(duration, currentTime + 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, currentTime, duration]);
// 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 (
@@ -125,7 +391,6 @@ export default function CompareVersionsPage() {
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</div>
@@ -136,12 +401,12 @@ export default function CompareVersionsPage() {
<Skeleton className="h-6 w-40 rounded-md" />
</div>
<div className="flex-1 bg-black" />
<div className="shrink-0 px-4 py-2 bg-background border-t">
<Skeleton className="h-4 w-24 mx-auto" />
</div>
</div>
))}
</div>
<div className="shrink-0 px-4 py-2 border-t">
<Skeleton className="h-8 w-full rounded" />
</div>
</div>
);
}
@@ -162,7 +427,11 @@ export default function CompareVersionsPage() {
}
return (
<div className="h-screen flex flex-col bg-background overflow-hidden">
<div
className="h-screen flex flex-col bg-background overflow-hidden"
onMouseUp={handleTimelineMouseUp}
onMouseLeave={() => isDragging && handleTimelineMouseUp()}
>
{/* Header */}
<div className="shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50">
<div className="flex items-center gap-3">
@@ -171,41 +440,54 @@ export default function CompareVersionsPage() {
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Video
Back
</Link>
<Separator orientation="vertical" className="h-5" />
<div className="flex items-center gap-2">
<GitCompareArrows className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Compare Versions</span>
<span className="text-xs text-muted-foreground"> {video.title}</span>
<span className="text-xs text-muted-foreground hidden sm:inline"> {video.title}</span>
</div>
</div>
</div>
{/* Side-by-side comparison */}
<div className="flex-1 flex overflow-hidden">
{/* Left panel */}
<div className="flex-1 flex flex-col border-r">
<div className="shrink-0 flex items-center justify-between p-3 border-b bg-muted/30">
{/* Video panels */}
<div className="flex-1 flex overflow-hidden min-h-0">
{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 (
<div
key={`${versionId}-${index}`}
className="flex-1 flex flex-col border-r last:border-r-0 min-w-0 overflow-hidden"
>
{/* Panel header */}
<div className="shrink-0 flex items-center justify-between p-2 border-b bg-muted/30">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Badge variant="secondary" className="mr-2">
v{leftVersion?.versionNumber}
<Badge variant="secondary" className="mr-1.5">
v{version.versionNumber}
</Badge>
{leftVersion?.versionLabel || `Version ${leftVersion?.versionNumber}`}
<ChevronDown className="h-4 w-4 ml-2" />
<span className="truncate max-w-[100px]">
{version.versionLabel || `Version ${version.versionNumber}`}
</span>
<ChevronDown className="h-3.5 w-3.5 ml-1.5 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{video.versions.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => setLeftVersionId(v.id)}
disabled={v.id === rightVersionId}
onClick={() => handleChangeVersion(index, v.id)}
disabled={usedVersionIds.has(v.id) && v.id !== version.id}
>
<Badge
variant={v.id === leftVersionId ? 'default' : 'secondary'}
variant={v.id === version.id ? 'default' : 'secondary'}
className="mr-2"
>
v{v.versionNumber}
@@ -216,88 +498,272 @@ export default function CompareVersionsPage() {
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{leftVersion?._count.comments || 0} comments
</div>
</div>
<div className="flex-1 bg-black flex items-center justify-center p-2">
{leftVersion && (
<iframe
src={getEmbedUrl(leftVersion)}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
const player = playersRef.current.get(versionId);
if (!player) return;
const isMuted = mutedPanels.has(versionId);
try {
if (isMuted) { player.unMute(); } else { player.mute(); }
} catch { /* */ }
setMutedPanels((prev) => {
const next = new Set(prev);
if (isMuted) { next.delete(versionId); } else { next.add(versionId); }
return next;
});
}}
title={mutedPanels.has(versionId) ? 'Unmute' : 'Mute'}
>
{mutedPanels.has(versionId) ? (
<VolumeX className="h-3.5 w-3.5" />
) : (
<Volume2 className="h-3.5 w-3.5" />
)}
</div>
{leftVersion?.duration && (
<div className="shrink-0 px-3 py-2 border-t text-xs text-muted-foreground text-center">
Duration: {Math.floor(leftVersion.duration / 60)}:
{(leftVersion.duration % 60).toString().padStart(2, '0')}
</div>
)}
</div>
{/* Right panel */}
<div className="flex-1 flex flex-col">
<div className="shrink-0 flex items-center justify-between p-3 border-b bg-muted/30">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Badge variant="secondary" className="mr-2">
v{rightVersion?.versionNumber}
</Badge>
{rightVersion?.versionLabel || `Version ${rightVersion?.versionNumber}`}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{video.versions.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => setRightVersionId(v.id)}
disabled={v.id === leftVersionId}
<Button
variant={isCommentsOpen ? 'secondary' : 'ghost'}
size="sm"
className="gap-1.5 text-xs"
onClick={() => toggleComments(versionId)}
>
<Badge
variant={v.id === rightVersionId ? 'default' : 'secondary'}
className="mr-2"
>
v{v.versionNumber}
</Badge>
{v.versionLabel || `Version ${v.versionNumber}`}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{rightVersion?._count.comments || 0} comments
<MessageSquare className="h-3.5 w-3.5" />
{version._count.comments}
</Button>
</div>
</div>
<div className="flex-1 bg-black flex items-center justify-center p-2">
{rightVersion && (
{/* Video embed with click-to-play overlay */}
<div
className={cn(
'bg-black flex items-center justify-center relative cursor-pointer group',
isCommentsOpen ? 'h-[55%]' : 'flex-1'
)}
onClick={handlePlayPause}
>
{version.providerId === 'youtube' ? (
<YouTubePanel
key={versionId}
version={version}
isApiLoaded={isApiLoaded}
onRegister={registerPlayer}
onUnregister={unregisterPlayer}
/>
) : (
<iframe
src={getEmbedUrl(rightVersion)}
className="w-full h-full"
src={version.originalUrl}
className="w-full h-full pointer-events-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
)}
{/* Play/pause overlay */}
<div
className={cn(
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 pointer-events-none',
isPlaying ? 'opacity-0 group-hover:opacity-100' : 'opacity-100'
)}
>
<div className="w-14 h-14 rounded-full bg-black/60 flex items-center justify-center">
{isPlaying ? (
<Pause className="h-7 w-7 text-white" />
) : (
<Play className="h-7 w-7 text-white ml-1" />
)}
</div>
</div>
</div>
{rightVersion?.duration && (
<div className="shrink-0 px-3 py-2 border-t text-xs text-muted-foreground text-center">
Duration: {Math.floor(rightVersion.duration / 60)}:
{(rightVersion.duration % 60).toString().padStart(2, '0')}
{/* Read-only comments panel */}
{isCommentsOpen && (
<div className="h-[45%] flex flex-col border-t bg-card overflow-hidden">
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b">
<div className="flex items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Comments</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{panelComments.length}
</Badge>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setOpenCommentsPanel(null)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{isLoadingComments ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : panelComments.length === 0 ? (
<div className="text-center py-6 text-muted-foreground text-xs">
No comments on this version
</div>
) : (
[...panelComments]
.sort((a, b) => a.timestamp - b.timestamp)
.map((comment) => {
const authorName = comment.author?.name || comment.guestName || 'Anonymous';
return (
<div
key={comment.id}
className={cn('rounded-lg border p-2 text-xs', comment.isResolved && 'opacity-60')}
>
<div className="flex items-center gap-1.5 mb-1">
<Avatar className="h-4 w-4">
<AvatarImage src={comment.author?.image ?? undefined} />
<AvatarFallback className="text-[8px]">{authorName.charAt(0)}</AvatarFallback>
</Avatar>
<span className="font-medium truncate">{authorName}</span>
<button
onClick={(e) => { e.stopPropagation(); handleSeek(comment.timestamp); }}
className="ml-auto flex items-center gap-0.5 text-primary bg-primary/10 px-1 py-0.5 rounded text-[10px] hover:bg-primary/20 transition-colors"
>
<Clock className="h-2.5 w-2.5" />
{formatTime(comment.timestamp)}
</button>
</div>
{comment.content && (
<p className="text-muted-foreground leading-relaxed">{comment.content}</p>
)}
{comment.tag && (
<Badge
variant="outline"
className="mt-1 text-[10px] px-1.5 py-0"
style={{ borderColor: comment.tag.color, color: comment.tag.color }}
>
{comment.tag.name}
</Badge>
)}
</div>
);
})
)}
</div>
</div>
)}
</div>
);
})}
</div>
{/* Shared playback controls */}
<div className="shrink-0 px-4 py-2 bg-background border-t">
<div className="flex items-center gap-2 mb-2">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePlayPause}>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
</Button>
<span className="text-xs text-muted-foreground tabular-nums">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div
ref={timelineRef}
className="relative h-8 bg-muted rounded cursor-pointer select-none"
onMouseDown={handleTimelineMouseDown}
onMouseMove={handleTimelineMouseMove}
>
{/* Progress bar */}
<div
className="absolute left-0 top-0 h-full bg-primary/30 rounded pointer-events-none"
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
/>
{/* Playhead */}
<div
className="absolute top-0 h-full w-1 bg-primary rounded pointer-events-none"
style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }}
/>
{/* Comment markers on timeline */}
{allTimelineComments.map((comment) => {
const markerColor = comment.tag?.color || (comment.isResolved ? '#22C55E' : '#22D3EE');
return (
<button
key={comment.id}
onClick={(e) => {
e.stopPropagation();
handleSeek(comment.timestamp);
}}
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10"
style={{
left: `calc(${duration > 0 ? (comment.timestamp / duration) * 100 : 0}% - 6px)`,
backgroundColor: markerColor,
}}
title={`v${comment.versionNumber}${formatTime(comment.timestamp)} - ${comment.content?.substring(0, 30) || '(comment)'}...`}
/>
);
})}
</div>
</div>
</div>
);
}
// Isolated YouTube player component per panel
function YouTubePanel({
version,
isApiLoaded,
onRegister,
onUnregister,
}: {
version: Version;
isApiLoaded: boolean;
onRegister: (versionId: string, player: YT.Player) => void;
onUnregister: (versionId: string) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<YT.Player | null>(null);
useEffect(() => {
if (!isApiLoaded || !containerRef.current) return;
const initPlayer = () => {
if (!containerRef.current) return;
const player = new window.YT.Player(containerRef.current, {
videoId: version.videoId,
playerVars: {
rel: 0,
modestbranding: 1,
enablejsapi: 1,
controls: 0,
showinfo: 0,
iv_load_policy: 3,
disablekb: 1,
} as YT.PlayerVars,
events: {
onReady: () => {
onRegister(version.id, player);
},
},
});
playerRef.current = player;
};
if (window.YT?.Player) {
const timeout = setTimeout(initPlayer, 50);
return () => {
clearTimeout(timeout);
onUnregister(version.id);
if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* */ }
playerRef.current = null;
}
};
}
return () => {
onUnregister(version.id);
if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* */ }
playerRef.current = null;
}
};
}, [version.id, version.videoId, isApiLoaded, onRegister, onUnregister]);
return <div ref={containerRef} className="w-full h-full pointer-events-none" />;
}
+1 -1
View File
@@ -59,7 +59,7 @@ export function Header({ user }: HeaderProps) {
const [shortcutsOpen, setShortcutsOpen] = useState(false);
// Hide header on video player pages — they use full viewport with their own back button
const isVideoPage = /\/videos\/[^/]+$/.test(pathname) || pathname.startsWith('/watch/');
const isVideoPage = /\/videos\/[^/]+($|\/compare)/.test(pathname) || pathname.startsWith('/watch/');
if (isVideoPage) return null;
return (
+91 -7
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { List } from 'react-window';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { usePathname, useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
ArrowLeft,
@@ -256,6 +256,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [guestName, setGuestName] = useState('');
const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard');
// Compare dialog state
const [showCompareDialog, setShowCompareDialog] = useState(false);
const [selectedCompareVersions, setSelectedCompareVersions] = useState<Set<string>>(new Set());
const router = useRouter();
useEffect(() => {
const saved = localStorage.getItem('openframe_guest_name');
if (saved) {
@@ -2206,11 +2211,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</Dialog>
{video.versions.length >= 2 && (
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${propProjectId}/videos/${videoId}/compare`}>
<Button variant="outline" size="sm" onClick={() => {
setSelectedCompareVersions(new Set(activeVersionId ? [activeVersionId] : []));
setShowCompareDialog(true);
}}>
<GitCompareArrows className="h-4 w-4 mr-1" />
Compare
</Link>
</Button>
)}
</div>
@@ -2229,11 +2235,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
New Version
</DropdownMenuItem>
{video.versions.length >= 2 && (
<DropdownMenuItem asChild>
<Link href={`/projects/${propProjectId}/videos/${videoId}/compare`}>
<DropdownMenuItem onSelect={() => {
setSelectedCompareVersions(new Set(activeVersionId ? [activeVersionId] : []));
setShowCompareDialog(true);
}}>
<GitCompareArrows className="h-4 w-4 mr-2" />
Compare
</Link>
</DropdownMenuItem>
)}
</DropdownMenuContent>
@@ -3449,6 +3456,83 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</div>
</DialogContent>
</Dialog>
{/* Compare Version Selection Dialog */}
<Dialog open={showCompareDialog} onOpenChange={setShowCompareDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Select Versions to Compare</DialogTitle>
<DialogDescription>
Choose 2 or more versions to compare side by side.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 mt-2 max-h-64 overflow-y-auto">
{video.versions
.slice()
.sort((a, b) => a.versionNumber - b.versionNumber)
.map((v) => {
const isSelected = selectedCompareVersions.has(v.id);
return (
<button
key={v.id}
type="button"
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border text-left transition-colors',
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:bg-accent/50'
)}
onClick={() => {
setSelectedCompareVersions((prev) => {
const next = new Set(prev);
if (next.has(v.id)) {
next.delete(v.id);
} else {
next.add(v.id);
}
return next;
});
}}
>
<div
className={cn(
'h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors',
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/40'
)}
>
{isSelected && (
<CheckCircle2 className="h-3 w-3" />
)}
</div>
<Badge variant="secondary">v{v.versionNumber}</Badge>
<span className="text-sm font-medium truncate">
{v.versionLabel || `Version ${v.versionNumber}`}
</span>
<span className="ml-auto text-xs text-muted-foreground shrink-0">
{v._count.comments} comments
</span>
</button>
);
})}
</div>
<Button
className="w-full mt-2"
disabled={selectedCompareVersions.size < 2}
onClick={() => {
const ids = Array.from(selectedCompareVersions).join(',');
setShowCompareDialog(false);
router.push(
`/projects/${propProjectId}/videos/${videoId}/compare?versions=${ids}`
);
}}
>
<GitCompareArrows className="h-4 w-4 mr-2" />
Compare {selectedCompareVersions.size} Versions
</Button>
</DialogContent>
</Dialog>
</div >
);
}