|
@@ -3,7 +3,7 @@
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
|
import { useAuth } from '@/lib/auth-context';
|
|
import { useAuth } from '@/lib/auth-context';
|
|
|
-import { assetsApi, commentsApi, AssetWithComments, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
|
|
|
|
|
|
|
+import { assetsApi, commentsApi, AssetWithComments, Asset, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
|
|
|
import { Avatar } from '@/components/ui/avatar';
|
|
import { Avatar } from '@/components/ui/avatar';
|
|
|
import { VideoPlayer } from '@/components/video-player/VideoPlayer';
|
|
import { VideoPlayer } from '@/components/video-player/VideoPlayer';
|
|
|
import { Tool } from '@/components/video-player/AnnotationCanvas';
|
|
import { Tool } from '@/components/video-player/AnnotationCanvas';
|
|
@@ -58,6 +58,36 @@ export default function ReviewPage() {
|
|
|
// Portrait / landscape detection
|
|
// Portrait / landscape detection
|
|
|
const [isPortrait, setIsPortrait] = useState(false);
|
|
const [isPortrait, setIsPortrait] = useState(false);
|
|
|
|
|
|
|
|
|
|
+ // ── Side-by-side compare mode ────────────────────────────────────────────
|
|
|
|
|
+ const [compareMode, setCompareMode] = useState(false);
|
|
|
|
|
+ const [compareAsset, setCompareAsset] = useState<Asset | null>(null);
|
|
|
|
|
+ const [showComparePicker, setShowComparePicker] = useState(false);
|
|
|
|
|
+ const [projectAssets, setProjectAssets] = useState<Asset[]>([]);
|
|
|
|
|
+ const [compareMismatch, setCompareMismatch] = useState<string | null>(null);
|
|
|
|
|
+ const [playing, setPlaying] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ const handleCompareSelect = useCallback((compareAssetArg: Asset) => {
|
|
|
|
|
+ setShowComparePicker(false);
|
|
|
|
|
+ setCompareMismatch(null);
|
|
|
|
|
+ const dur1 = asset?.duration ?? 0;
|
|
|
|
|
+ const dur2 = compareAssetArg.duration ?? 0;
|
|
|
|
|
+ const fps = asset?.fps ?? compareAssetArg.fps ?? 30;
|
|
|
|
|
+ const diffFrames = Math.abs(dur1 - dur2) * fps;
|
|
|
|
|
+ if (diffFrames > 5) {
|
|
|
|
|
+ setCompareMismatch(
|
|
|
|
|
+ `Videos differ by ${Math.round(diffFrames)} frames. Playback may be out of sync.`
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ setCompareAsset(compareAssetArg);
|
|
|
|
|
+ setCompareMode(true);
|
|
|
|
|
+ }, [asset]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleExitCompare = useCallback(() => {
|
|
|
|
|
+ setCompareMode(false);
|
|
|
|
|
+ setCompareAsset(null);
|
|
|
|
|
+ setCompareMismatch(null);
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const mq = window.matchMedia('(orientation: portrait)');
|
|
const mq = window.matchMedia('(orientation: portrait)');
|
|
|
setIsPortrait(mq.matches);
|
|
setIsPortrait(mq.matches);
|
|
@@ -397,6 +427,34 @@ export default function ReviewPage() {
|
|
|
|
|
|
|
|
<div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
|
|
<div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
|
|
|
|
|
|
|
|
|
|
+ {/* Compare mode toggle */}
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ if (compareMode) {
|
|
|
|
|
+ handleExitCompare();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ setShowComparePicker(true);
|
|
|
|
|
+ if (token && asset) {
|
|
|
|
|
+ assetsApi.list(token, asset.projectId).then(({ assets }) => {
|
|
|
|
|
+ setProjectAssets(assets.filter(a => a.id !== assetId && a.transcodeStatus === 'COMPLETED'));
|
|
|
|
|
+ }).catch(() => {});
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0 ${
|
|
|
|
|
+ compareMode
|
|
|
|
|
+ ? 'bg-indigo-600 text-white'
|
|
|
|
|
+ : ''
|
|
|
|
|
+ }`}
|
|
|
|
|
+ style={!compareMode ? { color: '#818CF8', background: 'rgba(129,140,248,0.10)' } : {}}
|
|
|
|
|
+ title="Side-by-side comparison"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <span className="hidden sm:inline">{compareMode ? 'Exit Compare' : 'Compare'}</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
{/* Status selector */}
|
|
{/* Status selector */}
|
|
|
<div className="relative shrink-0">
|
|
<div className="relative shrink-0">
|
|
|
<button
|
|
<button
|
|
@@ -439,6 +497,60 @@ export default function ReviewPage() {
|
|
|
</div>
|
|
</div>
|
|
|
</header>
|
|
</header>
|
|
|
|
|
|
|
|
|
|
+ {/* ── Compare picker modal ─────────────────────────────────────────────── */}
|
|
|
|
|
+ {showComparePicker && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="fixed inset-0 z-50" onClick={() => setShowComparePicker(false)} />
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-2xl overflow-hidden w-full max-w-md"
|
|
|
|
|
+ style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-modal)' }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="px-5 py-4 flex items-center justify-between" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
|
|
+ <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Select video to compare</h2>
|
|
|
|
|
+ <button onClick={() => setShowComparePicker(false)} className="w-7 h-7 flex items-center justify-center rounded-lg transition-colors hover:bg-white/10"
|
|
|
|
|
+ style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="p-2 max-h-80 overflow-y-auto">
|
|
|
|
|
+ {projectAssets.length === 0 ? (
|
|
|
|
|
+ <p className="text-sm text-center py-8" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
+ No other completed videos in this project.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ projectAssets.map(a => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={a.id}
|
|
|
|
|
+ onClick={() => handleCompareSelect(a)}
|
|
|
|
|
+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors hover:bg-white/5"
|
|
|
|
|
+ >
|
|
|
|
|
+ {a.thumbnail ? (
|
|
|
|
|
+ <img src={`${API_BASE}/uploads/${a.thumbnail}`} className="w-16 h-10 rounded-lg object-cover shrink-0" alt={a.title} />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="w-16 h-10 rounded-lg shrink-0 flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.06)' }}>
|
|
|
|
|
+ <svg className="w-5 h-5" style={{ color: 'rgba(255,255,255,0.2)' }} fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
+ <path d="M8 5v14l11-7z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <p className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{a.title}</p>
|
|
|
|
|
+ <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
+ {a.duration ? `${Math.floor(a.duration / 60)}:${Math.floor(a.duration % 60).toString().padStart(2, '0')}` : '—'}
|
|
|
|
|
+ {' · '}
|
|
|
|
|
+ {a.filename}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{/* ── Body ───────────────────────────────────────────── */}
|
|
{/* ── Body ───────────────────────────────────────────── */}
|
|
|
{/* Landscape: side-by-side | Portrait: stacked (video top, comments bottom) */}
|
|
{/* Landscape: side-by-side | Portrait: stacked (video top, comments bottom) */}
|
|
|
<div
|
|
<div
|
|
@@ -456,6 +568,66 @@ export default function ReviewPage() {
|
|
|
: { flex: 1, overflowY: 'auto' }}
|
|
: { flex: 1, overflowY: 'auto' }}
|
|
|
>
|
|
>
|
|
|
|
|
|
|
|
|
|
+ {/* ── Side-by-side compare layout ───────────────────────── */}
|
|
|
|
|
+ {compareMode ? (
|
|
|
|
|
+ <div className="flex gap-2 w-full">
|
|
|
|
|
+ {/* Main video */}
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
|
|
|
|
|
+ {asset.title}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <VideoPlayer
|
|
|
|
|
+ src={videoUrl}
|
|
|
|
|
+ mimeType={asset.mimeType}
|
|
|
|
|
+ fps={fps}
|
|
|
|
|
+ comments={[]}
|
|
|
|
|
+ visibleAnnotations={[]}
|
|
|
|
|
+ drawMode={false}
|
|
|
|
|
+ drawTool={drawTool}
|
|
|
|
|
+ drawColor={drawColor}
|
|
|
|
|
+ onDrawModeChange={() => {}}
|
|
|
|
|
+ onDrawToolChange={() => {}}
|
|
|
|
|
+ onDrawColorChange={() => {}}
|
|
|
|
|
+ pendingStrokes={[]}
|
|
|
|
|
+ onStrokeComplete={() => {}}
|
|
|
|
|
+ onTimeUpdate={handleTimeUpdate}
|
|
|
|
|
+ onCommentClick={() => {}}
|
|
|
|
|
+ externalCurrentTime={currentTime}
|
|
|
|
|
+ externalPlaying={playing}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Compare video */}
|
|
|
|
|
+ {compareAsset && (
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
|
|
|
|
|
+ {compareAsset.title}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <VideoPlayer
|
|
|
|
|
+ src={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
|
|
|
|
|
+ mimeType={compareAsset.mimeType}
|
|
|
|
|
+ fps={compareAsset.fps ?? 30}
|
|
|
|
|
+ comments={[]}
|
|
|
|
|
+ visibleAnnotations={[]}
|
|
|
|
|
+ drawMode={false}
|
|
|
|
|
+ drawTool={drawTool}
|
|
|
|
|
+ drawColor={drawColor}
|
|
|
|
|
+ onDrawModeChange={() => {}}
|
|
|
|
|
+ onDrawToolChange={() => {}}
|
|
|
|
|
+ onDrawColorChange={() => {}}
|
|
|
|
|
+ pendingStrokes={[]}
|
|
|
|
|
+ onStrokeComplete={() => {}}
|
|
|
|
|
+ onTimeUpdate={() => {}}
|
|
|
|
|
+ onCommentClick={() => {}}
|
|
|
|
|
+ isComparePlayer={true}
|
|
|
|
|
+ externalCurrentTime={currentTime}
|
|
|
|
|
+ externalPlaying={playing}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ /* ── Normal single-video layout ─────────────────────────── */
|
|
|
<VideoPlayer
|
|
<VideoPlayer
|
|
|
src={videoUrl}
|
|
src={videoUrl}
|
|
|
mimeType={asset.mimeType}
|
|
mimeType={asset.mimeType}
|
|
@@ -472,7 +644,20 @@ export default function ReviewPage() {
|
|
|
onStrokeComplete={handleStrokeComplete}
|
|
onStrokeComplete={handleStrokeComplete}
|
|
|
onTimeUpdate={handleTimeUpdate}
|
|
onTimeUpdate={handleTimeUpdate}
|
|
|
onCommentClick={handleCommentSeek}
|
|
onCommentClick={handleCommentSeek}
|
|
|
|
|
+ onPlayingChange={setPlaying}
|
|
|
/>
|
|
/>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* ── Compare mismatch warning ─────────────────────────── */}
|
|
|
|
|
+ {compareMode && compareMismatch && (
|
|
|
|
|
+ <div className="rounded-xl px-4 py-3 text-xs flex items-center gap-2"
|
|
|
|
|
+ style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.25)', color: '#FCD34D' }}>
|
|
|
|
|
+ <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ {compareMismatch}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
|
|
|
{/* Transcode status overlay — shown when video is not ready */}
|
|
{/* Transcode status overlay — shown when video is not ready */}
|
|
|
{transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
|
|
{transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
|
|
@@ -541,6 +726,7 @@ export default function ReviewPage() {
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
{/* Keyboard shortcuts */}
|
|
{/* Keyboard shortcuts */}
|
|
|
|
|
+ {!compareMode && (
|
|
|
<div className="flex flex-wrap gap-3 text-xs shrink-0 hidden sm:flex" style={{ color: 'var(--text-subtle)' }}>
|
|
<div className="flex flex-wrap gap-3 text-xs shrink-0 hidden sm:flex" style={{ color: 'var(--text-subtle)' }}>
|
|
|
<span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
|
|
<span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
|
|
|
<span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> seek ±5s</span>
|
|
<span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> seek ±5s</span>
|
|
@@ -549,6 +735,7 @@ export default function ReviewPage() {
|
|
|
<span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Esc</kbd> exit draw</span>
|
|
<span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Esc</kbd> exit draw</span>
|
|
|
<span className="font-mono text-[11px]">{formatTimecode(currentTime, fps, asset?.duration ?? 0)}</span>
|
|
<span className="font-mono text-[11px]">{formatTimecode(currentTime, fps, asset?.duration ?? 0)}</span>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* Resize handle — only shown in landscape */}
|
|
{/* Resize handle — only shown in landscape */}
|
|
@@ -595,6 +782,11 @@ export default function ReviewPage() {
|
|
|
>
|
|
>
|
|
|
{showResolved ? 'Hide resolved' : 'Show resolved'}
|
|
{showResolved ? 'Hide resolved' : 'Show resolved'}
|
|
|
</button>
|
|
</button>
|
|
|
|
|
+ {compareMode && (
|
|
|
|
|
+ <span className="text-[11px] px-2 py-0.5 rounded-md" style={{ background: 'rgba(99,102,241,0.15)', color: '#818CF8' }}>
|
|
|
|
|
+ Compare mode
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
@@ -714,9 +906,11 @@ export default function ReviewPage() {
|
|
|
<div className="flex-1 flex gap-2">
|
|
<div className="flex-1 flex gap-2">
|
|
|
<textarea
|
|
<textarea
|
|
|
className="input flex-1"
|
|
className="input flex-1"
|
|
|
- value={newComment}
|
|
|
|
|
|
|
+ value={compareMode ? '' : newComment}
|
|
|
onChange={e => setNewComment(e.target.value)}
|
|
onChange={e => setNewComment(e.target.value)}
|
|
|
- placeholder={replyTo ? 'Write a reply…' : 'Add a comment…'}
|
|
|
|
|
|
|
+ placeholder={compareMode ? 'Comments disabled in compare mode' : replyTo ? 'Write a reply…' : 'Add a comment…'}
|
|
|
|
|
+ disabled={compareMode}
|
|
|
|
|
+ readOnly={compareMode}
|
|
|
rows={1}
|
|
rows={1}
|
|
|
style={{ resize: 'none', overflow: 'hidden' }}
|
|
style={{ resize: 'none', overflow: 'hidden' }}
|
|
|
onKeyDown={e => {
|
|
onKeyDown={e => {
|