mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
770 lines
28 KiB
TypeScript
770 lines
28 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import Link from 'next/link';
|
|
import { useParams, 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 { 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;
|
|
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')}`;
|
|
}
|
|
|
|
export default function CompareVersionsPage() {
|
|
const params = useParams();
|
|
const searchParams = useSearchParams();
|
|
const projectId = params.projectId as string;
|
|
const videoId = params.videoId as string;
|
|
|
|
const [video, setVideo] = useState<VideoData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [isApiLoaded, setIsApiLoaded] = useState(false);
|
|
|
|
// 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 {
|
|
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`);
|
|
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 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 (
|
|
<div className="h-screen flex flex-col bg-background overflow-hidden">
|
|
<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">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Separator orientation="vertical" className="h-5" />
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-4 w-4" />
|
|
<Skeleton className="h-4 w-36" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{Array.from({ length: 2 }).map((_, i) => (
|
|
<div key={i} className="flex-1 flex flex-col overflow-hidden border-r last:border-r-0">
|
|
<div className="shrink-0 flex items-center justify-center h-10 px-4 border-b bg-muted/30">
|
|
<Skeleton className="h-6 w-40 rounded-md" />
|
|
</div>
|
|
<div className="flex-1 bg-black" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="shrink-0 px-4 py-2 border-t">
|
|
<Skeleton className="h-8 w-full rounded" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !video || video.versions.length < 2) {
|
|
return (
|
|
<div className="h-screen flex items-center justify-center bg-background">
|
|
<div className="text-center">
|
|
<p className="text-muted-foreground mb-4">
|
|
{error || 'Need at least 2 versions to compare'}
|
|
</p>
|
|
<Button asChild variant="outline">
|
|
<Link href={`/projects/${projectId}/videos/${videoId}`}>Back to Video</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<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">
|
|
<Link
|
|
href={`/projects/${projectId}/videos/${videoId}`}
|
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
|
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 hidden sm:inline">• {video.title}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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-1.5">
|
|
v{version.versionNumber}
|
|
</Badge>
|
|
<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={() => handleChangeVersion(index, v.id)}
|
|
disabled={usedVersionIds.has(v.id) && v.id !== version.id}
|
|
>
|
|
<Badge
|
|
variant={v.id === version.id ? 'default' : 'secondary'}
|
|
className="mr-2"
|
|
>
|
|
v{v.versionNumber}
|
|
</Badge>
|
|
{v.versionLabel || `Version ${v.versionNumber}`}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<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" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant={isCommentsOpen ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
className="gap-1.5 text-xs"
|
|
onClick={() => toggleComments(versionId)}
|
|
>
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
{version._count.comments}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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={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>
|
|
|
|
{/* 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" />;
|
|
}
|