Files
OpenFrame/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx
T
yusufipek 7357f24831 refactor(player): share the cursor idle hook and stop waking on element pauses
Move the cursor idle logic into useCursorIdle and use it from both the video
page and the compare page, which carried its own copy. Only pointer activity
wakes the cursor now: a pause/play pair the element emits on its own (a
rebuffer, a source switch) leaves the idle state alone instead of bringing
the chrome back for a second. The fullscreen-while-paused arming is gone,
nothing rendered it. The scrub test now leaves the player before pressing
the timeline, as the real layout forces.
2026-09-08 13:17:34 +03:00

1425 lines
49 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useCursorIdle } from '@/components/video-page/hooks/use-cursor-idle';
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<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 { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave } = useCursorIdle(isPlaying);
const timelineRef = useRef<HTMLDivElement>(null);
// Map of versionId -> YT.Player or Custom Adapter
const playersRef = useRef<Map<string, YT.Player | PlayerAdapter>>(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());
// 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<YT.Player | PlayerAdapter, number>());
// Direct DOM refs for progress bar / playhead / timecode — updated in the RAF loop
const progressBarRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null);
const timecodeRef = useRef<HTMLSpanElement>(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]);
// 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 (
<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',
cursorIdle && isPlaying && 'cursor-none',
isCommentsOpen ? 'h-[55%]' : 'flex-1'
)}
onClick={handlePlayPause}
onMouseMove={handleVideoMouseMove}
onMouseLeave={handleVideoMouseLeave}
>
{version.providerId === 'youtube' ? (
<YouTubePanel
key={versionId}
version={version}
isApiLoaded={isApiLoaded}
onRegister={registerPlayer}
onUnregister={unregisterPlayer}
/>
) : version.providerId === 'bunny' ? (
<BunnyPanel
key={versionId}
version={version}
onRegister={registerPlayer}
onUnregister={unregisterPlayer}
/>
) : version.providerId === 'r2' ? (
<R2Panel
key={versionId}
version={version}
onRegister={registerPlayer}
onUnregister={unregisterPlayer}
/>
) : isSafeUrl(version.originalUrl) ? (
<iframe
src={version.originalUrl}
className="w-full h-full pointer-events-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : null}
{/* 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
? cursorIdle
? 'opacity-0'
: '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 whitespace-pre-wrap break-words">
{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 ref={timecodeRef} 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
ref={progressBarRef}
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
ref={playheadRef}
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" />;
}
// Isolated R2 direct-upload panel: a plain <video> over the app's upload
// route, mapped to the shared adapter interface. Kept separate from
// BunnyPanel, whose HLS processing-retry logic does not apply to R2 files.
function R2Panel({
version,
onRegister,
onUnregister,
}: {
version: Version;
onRegister: (versionId: string, player: YT.Player | PlayerAdapter) => void;
onUnregister: (versionId: string) => void;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [portraitFrameWidth, setPortraitFrameWidth] = useState<number>(0);
const [isPortraitSource, setIsPortraitSource] = useState(false);
useEffect(() => {
const panelEl = panelRef.current;
if (!panelEl || typeof ResizeObserver === 'undefined') return;
const updateFrameWidth = () => {
const panelWidth = panelEl.clientWidth;
const panelHeight = panelEl.clientHeight;
if (panelWidth <= 0 || panelHeight <= 0) return;
setPortraitFrameWidth(Math.min(panelWidth, panelHeight * (9 / 16)));
};
updateFrameWidth();
const observer = new ResizeObserver(updateFrameWidth);
observer.observe(panelEl);
return () => observer.disconnect();
}, []);
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl) return;
// Same guard the rest of the page applies before putting a URL in the DOM:
// proxy paths must be a well-formed upload route, anything else http(s).
const playbackUrl = resolveR2PlaybackUrl(version);
if (!isPlayableVideoUrl(playbackUrl)) {
console.error('Unsafe R2 playback URL, panel not registered:', playbackUrl);
return;
}
let cachedTime = 0;
let cachedDuration = 0;
let isPlaying = false;
const onLoadedMetadata = () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
cachedDuration = videoEl.duration;
}
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
}
};
const onTimeUpdate = () => {
cachedTime = videoEl.currentTime || 0;
};
const onPlay = () => {
isPlaying = true;
};
const onPause = () => {
isPlaying = false;
};
const onEnded = () => {
isPlaying = false;
};
const adapter: PlayerAdapter = {
playVideo: () => {
videoEl.play().catch((err) => console.error('Error playing R2 panel video:', err));
},
pauseVideo: () => videoEl.pause(),
seekTo: (time: number) => {
cachedTime = time;
videoEl.currentTime = time;
},
mute: () => {
videoEl.muted = true;
},
unMute: () => {
videoEl.muted = false;
},
isMuted: () => videoEl.muted,
getCurrentTime: () => videoEl.currentTime || cachedTime,
getDuration: () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
cachedDuration = videoEl.duration;
}
return cachedDuration;
},
getPlayerState: () =>
isPlaying ? (window.YT?.PlayerState?.PLAYING ?? 1) : (window.YT?.PlayerState?.PAUSED ?? 2),
setPlaybackRate: (rate: number) => {
videoEl.playbackRate = rate;
},
destroy: () => {
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('ended', onEnded);
videoEl.removeAttribute('src');
videoEl.load();
},
};
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
videoEl.addEventListener('timeupdate', onTimeUpdate);
videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('ended', onEnded);
videoEl.src = playbackUrl;
videoEl.load();
onRegister(version.id, adapter);
return () => {
onUnregister(version.id);
adapter.destroy();
};
}, [version, onRegister, onUnregister]);
return (
<div
ref={panelRef}
className="relative w-full h-full group flex items-center justify-center bg-black"
>
<div
className={cn(
'relative flex items-center justify-center bg-black',
isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
)}
style={
isPortraitSource && portraitFrameWidth > 0
? { width: `${portraitFrameWidth}px` }
: undefined
}
>
<video
ref={videoRef}
className="w-full h-full object-contain pointer-events-none border-0 bg-black"
style={{
pointerEvents: 'none',
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'center',
backgroundColor: 'black',
}}
preload="metadata"
playsInline
/>
</div>
</div>
);
}
// Isolated Bunny Stream player component per panel mapped to the shared adapter interface
function BunnyPanel({
version,
onRegister,
onUnregister,
}: {
version: Version;
onRegister: (versionId: string, player: YT.Player | PlayerAdapter) => void;
onUnregister: (versionId: string) => void;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const [portraitFrameWidth, setPortraitFrameWidth] = useState<number>(0);
const [isPortraitSource, setIsPortraitSource] = useState(false);
useEffect(() => {
const panelEl = panelRef.current;
if (!panelEl || typeof ResizeObserver === 'undefined') return;
const updateFrameWidth = () => {
const panelWidth = panelEl.clientWidth;
const panelHeight = panelEl.clientHeight;
if (panelWidth <= 0 || panelHeight <= 0) return;
setPortraitFrameWidth(Math.min(panelWidth, panelHeight * (9 / 16)));
};
updateFrameWidth();
const observer = new ResizeObserver(updateFrameWidth);
observer.observe(panelEl);
return () => observer.disconnect();
}, []);
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl) return;
let cachedTime = 0;
let cachedDuration = 0;
let isPlaying = false;
let destroyed = false;
let retryAttempt = 0;
let sourceMode: 'hls' | 'original' = 'hls';
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const clearRetryTimer = () => {
if (!retryTimer) return;
clearTimeout(retryTimer);
retryTimer = null;
};
const getRetryUrl = (baseUrl: string) => {
retryAttempt += 1;
const separator = baseUrl.includes('?') ? '&' : '?';
return `${baseUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
};
const scheduleRetry = (retryFn: () => void) => {
clearRetryTimer();
retryTimer = setTimeout(() => {
if (!destroyed) {
retryFn();
}
}, 3000);
};
const adapter: PlayerAdapter = {
playVideo: () => {
videoEl.play().catch((err) => console.error('Error playing Bunny panel video:', err));
},
pauseVideo: () => videoEl.pause(),
seekTo: (time: number) => {
cachedTime = time;
videoEl.currentTime = time;
},
mute: () => {
videoEl.muted = true;
},
unMute: () => {
videoEl.muted = false;
},
isMuted: () => videoEl.muted,
getCurrentTime: () => videoEl.currentTime || cachedTime,
getDuration: () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
cachedDuration = videoEl.duration;
}
return cachedDuration;
},
getPlayerState: () =>
isPlaying ? (window.YT?.PlayerState?.PLAYING ?? 1) : (window.YT?.PlayerState?.PAUSED ?? 2),
setPlaybackRate: (rate: number) => {
videoEl.playbackRate = rate;
},
destroy: () => {
destroyed = true;
clearRetryTimer();
videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
videoEl.removeEventListener('error', onError);
if (hlsRef.current) {
try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null;
}
videoEl.removeAttribute('src');
videoEl.load();
},
};
const onLoadedMetadata = () => {
clearRetryTimer();
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
cachedDuration = videoEl.duration;
}
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
}
};
const onTimeUpdate = () => {
cachedTime = videoEl.currentTime || 0;
};
const onPlay = () => {
isPlaying = true;
};
const onPause = () => {
isPlaying = false;
};
const onEnded = () => {
isPlaying = false;
};
if (!bunnyCdnHostname) {
return;
}
const hlsUrl = `https://${bunnyCdnHostname}/${version.videoId}/playlist.m3u8`;
const originalUrl = `https://${bunnyCdnHostname}/${version.videoId}/original`;
const activateOriginalFallback = (): void => {
sourceMode = 'original';
clearRetryTimer();
if (hlsRef.current) {
try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null;
}
videoEl.src = getRetryUrl(originalUrl);
videoEl.load();
};
const onError = () => {
if (destroyed) return;
if (videoEl.readyState >= HTMLMediaElement.HAVE_METADATA) {
return;
}
if (sourceMode === 'hls') {
activateOriginalFallback();
return;
}
scheduleRetry(() => {
videoEl.src = getRetryUrl(originalUrl);
videoEl.load();
});
};
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
videoEl.addEventListener('timeupdate', onTimeUpdate);
videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('ended', onEnded);
videoEl.addEventListener('error', onError);
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
sourceMode = 'hls';
videoEl.src = hlsUrl;
videoEl.load();
} else if (Hls.isSupported()) {
sourceMode = 'hls';
const hls = new Hls();
hlsRef.current = hls;
hls.attachMedia(videoEl);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (!destroyed) {
hls.loadSource(hlsUrl);
}
});
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (destroyed) return;
clearRetryTimer();
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (destroyed) return;
const responseCode = (data as { response?: { code?: number } }).response?.code;
const isManifestLoadFailure =
data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR ||
data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
const hasProcessingLikeStatus =
responseCode === undefined ||
responseCode === 0 ||
responseCode === 403 ||
responseCode === 404 ||
responseCode === 423 ||
responseCode === 429 ||
responseCode === 503;
const isLikelyProcessing = isManifestLoadFailure && hasProcessingLikeStatus;
const isNetworkPreMetadataProcessing =
data.type === Hls.ErrorTypes.NETWORK_ERROR &&
hasProcessingLikeStatus &&
videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
const isUnknownPreMetadataProcessing =
!data.details && !data.type && videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
if (
isLikelyProcessing ||
isNetworkPreMetadataProcessing ||
isUnknownPreMetadataProcessing
) {
if (sourceMode === 'hls') {
activateOriginalFallback();
return;
}
scheduleRetry(() => {
if (sourceMode === 'original') {
videoEl.src = getRetryUrl(originalUrl);
videoEl.load();
return;
}
const retryUrl = getRetryUrl(hlsUrl);
try {
hls.stopLoad();
} catch {
// ignore stop-load failures; loadSource is the important part
}
hls.loadSource(retryUrl);
hls.startLoad(-1);
});
return;
}
if (data.fatal) {
console.error('Fatal HLS error in compare panel:', data);
}
});
} else {
console.error('HLS is not supported in this browser.');
}
onRegister(version.id, adapter);
return () => {
onUnregister(version.id);
adapter.destroy();
};
}, [version.id, version.videoId, onRegister, onUnregister, bunnyCdnHostname]);
return (
<div
ref={panelRef}
className="relative w-full h-full group flex items-center justify-center bg-black"
>
<div
className={cn(
'relative flex items-center justify-center bg-black',
isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
)}
style={
isPortraitSource && portraitFrameWidth > 0
? { width: `${portraitFrameWidth}px` }
: undefined
}
>
<video
ref={videoRef}
className="w-full h-full object-contain pointer-events-none border-0 bg-black"
style={{
pointerEvents: 'none',
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'center',
backgroundColor: 'black',
}}
preload="metadata"
playsInline
/>
</div>
</div>
);
}