|
@@ -59,7 +59,6 @@ export function VideoPlayer({
|
|
|
const [muted, setMuted] = useState(false);
|
|
const [muted, setMuted] = useState(false);
|
|
|
const [playbackRate, setPlaybackRate] = useState(1);
|
|
const [playbackRate, setPlaybackRate] = useState(1);
|
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
|
- const [showControls, setShowControls] = useState(true);
|
|
|
|
|
const [dims, setDims] = useState({ width: 0, height: 0 });
|
|
const [dims, setDims] = useState({ width: 0, height: 0 });
|
|
|
// Speech bubble — auto-show within ±1s of comment timestamp
|
|
// Speech bubble — auto-show within ±1s of comment timestamp
|
|
|
const BUBBLE_WINDOW = 1;
|
|
const BUBBLE_WINDOW = 1;
|
|
@@ -92,7 +91,6 @@ export function VideoPlayer({
|
|
|
setBubbleVisible(hasMatch);
|
|
setBubbleVisible(hasMatch);
|
|
|
}, [currentTime, comments]);
|
|
}, [currentTime, comments]);
|
|
|
|
|
|
|
|
- const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
|
|
|
const redrawAnnotationsRef = useRef<(time: number) => void>(() => {});
|
|
const redrawAnnotationsRef = useRef<(time: number) => void>(() => {});
|
|
|
|
|
|
|
|
const fpsRef = useRef(fps);
|
|
const fpsRef = useRef(fps);
|
|
@@ -114,11 +112,22 @@ export function VideoPlayer({
|
|
|
videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
|
|
videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
|
|
|
}
|
|
}
|
|
|
videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
|
|
videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
|
|
|
|
|
+
|
|
|
|
|
+ // Immediate time update after any seek completes — keeps UI in sync with video
|
|
|
|
|
+ function onSeeked() {
|
|
|
|
|
+ const t = videoRef.current?.currentTime ?? 0;
|
|
|
|
|
+ setCurrentTime(t);
|
|
|
|
|
+ onTimeUpdate(t);
|
|
|
|
|
+ redrawAnnotationsRef.current(t);
|
|
|
|
|
+ }
|
|
|
|
|
+ video.addEventListener('seeked', onSeeked);
|
|
|
|
|
+
|
|
|
return () => {
|
|
return () => {
|
|
|
if (videoCallbackRef.current !== null) {
|
|
if (videoCallbackRef.current !== null) {
|
|
|
try { (video as any).cancelVideoFrameCallback(videoCallbackRef.current); } catch { /* */ }
|
|
try { (video as any).cancelVideoFrameCallback(videoCallbackRef.current); } catch { /* */ }
|
|
|
videoCallbackRef.current = null;
|
|
videoCallbackRef.current = null;
|
|
|
}
|
|
}
|
|
|
|
|
+ video.removeEventListener('seeked', onSeeked);
|
|
|
};
|
|
};
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
}, []);
|
|
}, []);
|
|
@@ -197,12 +206,6 @@ export function VideoPlayer({
|
|
|
return () => window.removeEventListener('keydown', handleKey);
|
|
return () => window.removeEventListener('keydown', handleKey);
|
|
|
}, [duration, drawMode, onDrawModeChange]);
|
|
}, [duration, drawMode, onDrawModeChange]);
|
|
|
|
|
|
|
|
- const resetHideTimer = useCallback(() => {
|
|
|
|
|
- setShowControls(true);
|
|
|
|
|
- clearTimeout(hideTimer.current);
|
|
|
|
|
- if (playing) hideTimer.current = setTimeout(() => setShowControls(false), 3000);
|
|
|
|
|
- }, [playing]);
|
|
|
|
|
-
|
|
|
|
|
function stepFrame(dir: 1 | -1) {
|
|
function stepFrame(dir: 1 | -1) {
|
|
|
const video = videoRef.current;
|
|
const video = videoRef.current;
|
|
|
if (!video) return;
|
|
if (!video) return;
|
|
@@ -231,14 +234,15 @@ export function VideoPlayer({
|
|
|
function toggleMute() {
|
|
function toggleMute() {
|
|
|
const video = videoRef.current;
|
|
const video = videoRef.current;
|
|
|
if (!video) return;
|
|
if (!video) return;
|
|
|
- if (muted || volume === 0) {
|
|
|
|
|
- video.volume = 1;
|
|
|
|
|
- setVolume(1);
|
|
|
|
|
- setMuted(false);
|
|
|
|
|
- } else {
|
|
|
|
|
|
|
+ const nextMuted = !video.muted;
|
|
|
|
|
+ video.muted = nextMuted;
|
|
|
|
|
+ setMuted(nextMuted);
|
|
|
|
|
+ if (nextMuted) {
|
|
|
video.volume = 0;
|
|
video.volume = 0;
|
|
|
setVolume(0);
|
|
setVolume(0);
|
|
|
- setMuted(true);
|
|
|
|
|
|
|
+ } else {
|
|
|
|
|
+ video.volume = volume > 0 ? volume : 1;
|
|
|
|
|
+ setVolume(video.volume);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -275,8 +279,6 @@ export function VideoPlayer({
|
|
|
<div
|
|
<div
|
|
|
ref={containerRef}
|
|
ref={containerRef}
|
|
|
className="relative bg-black rounded-xl overflow-hidden select-none group"
|
|
className="relative bg-black rounded-xl overflow-hidden select-none group"
|
|
|
- onMouseMove={resetHideTimer}
|
|
|
|
|
- onMouseLeave={() => playing && setShowControls(false)}
|
|
|
|
|
>
|
|
>
|
|
|
<video
|
|
<video
|
|
|
ref={videoRef}
|
|
ref={videoRef}
|
|
@@ -313,53 +315,6 @@ export function VideoPlayer({
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {/* Controls overlay (frame step + volume) on top of video */}
|
|
|
|
|
- {showControls && !drawMode && (
|
|
|
|
|
- <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none"
|
|
|
|
|
- style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.45) 0%, transparent 60%)' }}>
|
|
|
|
|
- {/* Center: previous frame | Play/Pause | next frame */}
|
|
|
|
|
- <div className="flex items-center gap-4 pointer-events-auto">
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={() => stepFrame(-1)}
|
|
|
|
|
- className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-90"
|
|
|
|
|
- style={{ background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(8px)' }}
|
|
|
|
|
- title="Previous frame (U)"
|
|
|
|
|
- >
|
|
|
|
|
- <svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
- <path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </button>
|
|
|
|
|
-
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={togglePlay}
|
|
|
|
|
- className="w-14 h-14 rounded-full flex items-center justify-center transition-all active:scale-90"
|
|
|
|
|
- style={{ background: 'rgba(99,102,241,0.85)', boxShadow: '0 0 24px rgba(99,102,241,0.4)' }}
|
|
|
|
|
- aria-label={playing ? 'Pause' : 'Play'}
|
|
|
|
|
- >
|
|
|
|
|
- {playing ? (
|
|
|
|
|
- <svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
- <path d="M6 4h4v16H6zM14 4h4v16h-4z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <svg className="w-6 h-6 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
- <path d="M8 5v14l11-7z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- )}
|
|
|
|
|
- </button>
|
|
|
|
|
-
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={() => stepFrame(1)}
|
|
|
|
|
- className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-90"
|
|
|
|
|
- style={{ background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(8px)' }}
|
|
|
|
|
- title="Next frame (I)"
|
|
|
|
|
- >
|
|
|
|
|
- <svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
- <path d="M18 18h-2V6h2zm-3.5 0L6 12z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
|
|
|
{/* Floating speech bubble — inside video frame */}
|
|
{/* Floating speech bubble — inside video frame */}
|
|
|
{activeComment && !drawMode && (
|
|
{activeComment && !drawMode && (
|
|
@@ -382,9 +337,19 @@ export function VideoPlayer({
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* ── Draw toolbar (always above controls on mobile) ──────────── */}
|
|
|
|
|
|
|
+ {/* Timeline */}
|
|
|
|
|
+ <Timeline
|
|
|
|
|
+ duration={duration}
|
|
|
|
|
+ currentTime={currentTime}
|
|
|
|
|
+ fps={fps}
|
|
|
|
|
+ comments={comments}
|
|
|
|
|
+ onSeek={handleSeek}
|
|
|
|
|
+ onCommentClick={onCommentClick}
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ {/* ── Draw toolbar (appears after timeline + bottom controls) ─── */}
|
|
|
{drawMode && (
|
|
{drawMode && (
|
|
|
- <div className="flex flex-wrap items-center gap-2 mt-2 px-1">
|
|
|
|
|
|
|
+ <div className="flex flex-wrap items-center gap-2 px-1 pb-2">
|
|
|
<span className="text-xs shrink-0" style={{ color: 'var(--text-muted)' }}>Draw:</span>
|
|
<span className="text-xs shrink-0" style={{ color: 'var(--text-muted)' }}>Draw:</span>
|
|
|
{(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
|
|
{(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
|
|
|
<button
|
|
<button
|
|
@@ -419,16 +384,6 @@ export function VideoPlayer({
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {/* Timeline */}
|
|
|
|
|
- <Timeline
|
|
|
|
|
- duration={duration}
|
|
|
|
|
- currentTime={currentTime}
|
|
|
|
|
- fps={fps}
|
|
|
|
|
- comments={comments}
|
|
|
|
|
- onSeek={handleSeek}
|
|
|
|
|
- onCommentClick={onCommentClick}
|
|
|
|
|
- />
|
|
|
|
|
-
|
|
|
|
|
{/* ── Bottom controls row ───────────────────────────────────────── */}
|
|
{/* ── Bottom controls row ───────────────────────────────────────── */}
|
|
|
<div className="flex items-center gap-1 px-1 pb-2">
|
|
<div className="flex items-center gap-1 px-1 pb-2">
|
|
|
|
|
|
|
@@ -439,7 +394,6 @@ export function VideoPlayer({
|
|
|
style={{ color: 'rgba(255,255,255,0.7)' }}
|
|
style={{ color: 'rgba(255,255,255,0.7)' }}
|
|
|
title={muted || volume === 0 ? 'Unmute' : 'Mute'}
|
|
title={muted || volume === 0 ? 'Unmute' : 'Mute'}
|
|
|
>
|
|
>
|
|
|
- {/* Volume bars SVG: 4 bars of decreasing height */}
|
|
|
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
{volBars === 0 ? (
|
|
{volBars === 0 ? (
|
|
|
<>
|
|
<>
|
|
@@ -478,6 +432,50 @@ export function VideoPlayer({
|
|
|
|
|
|
|
|
<div className="flex-1" />
|
|
<div className="flex-1" />
|
|
|
|
|
|
|
|
|
|
+ {/* 3 playback buttons — centered, touch-friendly */}
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => stepFrame(-1)}
|
|
|
|
|
+ className="w-10 h-10 md:w-9 md:h-9 flex items-center justify-center rounded-full transition-all active:scale-90"
|
|
|
|
|
+ style={{ background: 'rgba(255,255,255,0.12)' }}
|
|
|
|
|
+ title="Previous frame (U)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
+ <path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={togglePlay}
|
|
|
|
|
+ className="w-11 h-11 md:w-10 md:h-10 flex items-center justify-center rounded-full transition-all active:scale-90"
|
|
|
|
|
+ style={{ background: 'rgba(99,102,241,0.80)', boxShadow: '0 0 16px rgba(99,102,241,0.3)' }}
|
|
|
|
|
+ title={playing ? 'Pause (Space)' : 'Play (Space)'}
|
|
|
|
|
+ >
|
|
|
|
|
+ {playing ? (
|
|
|
|
|
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
+ <path d="M6 4h4v16H6zM14 4h4v16h-4z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
+ <path d="M8 5v14l11-7z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => stepFrame(1)}
|
|
|
|
|
+ className="w-10 h-10 md:w-9 md:h-9 flex items-center justify-center rounded-full transition-all active:scale-90"
|
|
|
|
|
+ style={{ background: 'rgba(255,255,255,0.12)' }}
|
|
|
|
|
+ title="Next frame (I)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
+ <path d="M18 18h-2V6h2zm-3.5 0L6 12z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex-1" />
|
|
|
|
|
+
|
|
|
{/* Speed — desktop only */}
|
|
{/* Speed — desktop only */}
|
|
|
<select
|
|
<select
|
|
|
value={playbackRate}
|
|
value={playbackRate}
|