|
|
@@ -61,18 +61,17 @@ export function VideoPlayer({
|
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
|
const [showControls, setShowControls] = useState(true);
|
|
|
const [dims, setDims] = useState({ width: 0, height: 0 });
|
|
|
- // Speech bubble state — derived from currentTime (auto-show within ±1s of comment)
|
|
|
- const BUBBLE_WINDOW = 1; // seconds before/after comment timestamp
|
|
|
+ // Speech bubble — auto-show within ±1s of comment timestamp
|
|
|
+ const BUBBLE_WINDOW = 1;
|
|
|
const [dismissedSet, setDismissedSet] = useState<Set<string>>(new Set());
|
|
|
const dismissedRef = useRef<Set<string>>(new Set());
|
|
|
useEffect(() => { dismissedRef.current = dismissedSet; }, [dismissedSet]);
|
|
|
const [bubbleVisible, setBubbleVisible] = useState(false);
|
|
|
|
|
|
- // Auto-detect which comment (if any) is within the ±1.5s window of currentTime
|
|
|
+ // Active comment within bubble window
|
|
|
const activeComment: Comment | null = (() => {
|
|
|
if (!bubbleVisible) return null;
|
|
|
const ts = comments.filter(c => c.timestamp != null && !c.deleted && !dismissedRef.current.has(c.id));
|
|
|
- // Find the comment whose timestamp is closest to currentTime within the window
|
|
|
let best: Comment | null = null;
|
|
|
let bestDist = Infinity;
|
|
|
for (const c of ts) {
|
|
|
@@ -85,7 +84,6 @@ export function VideoPlayer({
|
|
|
return best;
|
|
|
})();
|
|
|
|
|
|
- // Show bubble when any comment is within ±1.5s of currentTime
|
|
|
useEffect(() => {
|
|
|
const hasMatch = comments.some(
|
|
|
c => c.timestamp != null && !c.deleted && !dismissedRef.current.has(c.id) &&
|
|
|
@@ -97,7 +95,6 @@ export function VideoPlayer({
|
|
|
const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
|
const redrawAnnotationsRef = useRef<(time: number) => void>(() => {});
|
|
|
|
|
|
- // Refs so the rVFC callback always sees current values without re-registering
|
|
|
const fpsRef = useRef(fps);
|
|
|
const visibleAnnotationsRef = useRef(visibleAnnotations);
|
|
|
const drawModeRef = useRef(drawMode);
|
|
|
@@ -105,11 +102,10 @@ export function VideoPlayer({
|
|
|
visibleAnnotationsRef.current = visibleAnnotations;
|
|
|
drawModeRef.current = drawMode;
|
|
|
|
|
|
- // ── requestVideoFrameCallback loop — fires every rendered frame ─────────────────
|
|
|
+ // ── rVFC loop ───────────────────────────────────────────────────────────────
|
|
|
useEffect(() => {
|
|
|
const video = videoRef.current;
|
|
|
if (!video) return;
|
|
|
-
|
|
|
function onFrame(_now: number, metadata: { mediaTime: number }) {
|
|
|
const t = metadata.mediaTime;
|
|
|
setCurrentTime(t);
|
|
|
@@ -117,22 +113,20 @@ export function VideoPlayer({
|
|
|
redrawAnnotationsRef.current(t);
|
|
|
videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
|
|
|
}
|
|
|
-
|
|
|
videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
|
|
|
return () => {
|
|
|
if (videoCallbackRef.current !== null) {
|
|
|
- try { (video as any).cancelVideoFrameCallback(videoCallbackRef.current); } catch { /* ignore */ }
|
|
|
+ try { (video as any).cancelVideoFrameCallback(videoCallbackRef.current); } catch { /* */ }
|
|
|
videoCallbackRef.current = null;
|
|
|
}
|
|
|
};
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
}, []);
|
|
|
|
|
|
- // HLS setup
|
|
|
+ // HLS
|
|
|
useEffect(() => {
|
|
|
const video = videoRef.current;
|
|
|
if (!video || !src) return;
|
|
|
-
|
|
|
if (mimeType === 'application/x-mpegURL' || src.endsWith('.m3u8')) {
|
|
|
if (Hls.isSupported()) {
|
|
|
const hls = new Hls();
|
|
|
@@ -156,11 +150,10 @@ export function VideoPlayer({
|
|
|
return () => obs.disconnect();
|
|
|
}, []);
|
|
|
|
|
|
- const handleDismissBubble = useCallback((commentId: string) => {
|
|
|
- setDismissedSet(prev => new Set([...prev, commentId]));
|
|
|
+ const handleDismissBubble = useCallback((id: string) => {
|
|
|
+ setDismissedSet(prev => new Set([...prev, id]));
|
|
|
}, []);
|
|
|
|
|
|
- // ── Annotation draw function (ref-based, callable from rVFC callback) ───────────────
|
|
|
redrawAnnotationsRef.current = (time: number) => {
|
|
|
const canvas = displayCanvasRef.current;
|
|
|
if (!canvas) return;
|
|
|
@@ -168,18 +161,14 @@ export function VideoPlayer({
|
|
|
if (!ctx) return;
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
if (drawModeRef.current) return;
|
|
|
-
|
|
|
const anns = visibleAnnotationsRef.current;
|
|
|
if (!anns || anns.length === 0) return;
|
|
|
const frameRange = 3 / (fpsRef.current || 30);
|
|
|
for (const { annotation, timestamp } of anns) {
|
|
|
- if (Math.abs(time - timestamp) <= frameRange) {
|
|
|
- drawShape(ctx, annotation);
|
|
|
- }
|
|
|
+ if (Math.abs(time - timestamp) <= frameRange) drawShape(ctx, annotation);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- // Resize + initial draw
|
|
|
useEffect(() => {
|
|
|
const canvas = displayCanvasRef.current;
|
|
|
if (!canvas) return;
|
|
|
@@ -189,53 +178,29 @@ export function VideoPlayer({
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
}, [dims]);
|
|
|
|
|
|
- // Keyboard shortcuts
|
|
|
+ // ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
|
|
useEffect(() => {
|
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
|
const video = videoRef.current;
|
|
|
if (!video || e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
|
-
|
|
|
- if (e.code === 'Space') {
|
|
|
- e.preventDefault();
|
|
|
- video.paused ? video.play() : video.pause();
|
|
|
- }
|
|
|
- if (e.code === 'ArrowLeft') {
|
|
|
- e.preventDefault();
|
|
|
- video.currentTime = Math.max(0, video.currentTime - 5);
|
|
|
- }
|
|
|
- if (e.code === 'ArrowRight') {
|
|
|
- e.preventDefault();
|
|
|
- video.currentTime = Math.min(duration, video.currentTime + 5);
|
|
|
- }
|
|
|
- if (e.code === 'KeyC') {
|
|
|
- e.preventDefault();
|
|
|
- if (!drawMode) {
|
|
|
- video.pause();
|
|
|
- onDrawModeChange(true);
|
|
|
- } else {
|
|
|
- onDrawModeChange(false);
|
|
|
- }
|
|
|
- }
|
|
|
+ if (e.code === 'Space') { e.preventDefault(); video.paused ? video.play() : video.pause(); }
|
|
|
+ if (e.code === 'ArrowLeft') { e.preventDefault(); video.currentTime = Math.max(0, video.currentTime - 5); }
|
|
|
+ if (e.code === 'ArrowRight') { e.preventDefault(); video.currentTime = Math.min(duration, video.currentTime + 5); }
|
|
|
+ if (e.code === 'KeyC') { e.preventDefault(); onDrawModeChange(!drawMode); }
|
|
|
if (e.code === 'KeyF') { e.preventDefault(); toggleFullscreen(); }
|
|
|
- if (e.code === 'KeyM') { e.preventDefault(); video.muted = !video.muted; }
|
|
|
+ if (e.code === 'KeyM') { e.preventDefault(); toggleMute(); }
|
|
|
if (e.code === 'KeyU') { e.preventDefault(); stepFrame(-1); }
|
|
|
if (e.code === 'KeyI') { e.preventDefault(); stepFrame(1); }
|
|
|
- if (e.code === 'Escape' && drawMode) {
|
|
|
- e.preventDefault();
|
|
|
- onDrawModeChange(false);
|
|
|
- }
|
|
|
+ if (e.code === 'Escape' && drawMode) { e.preventDefault(); onDrawModeChange(false); }
|
|
|
};
|
|
|
window.addEventListener('keydown', handleKey);
|
|
|
return () => window.removeEventListener('keydown', handleKey);
|
|
|
}, [duration, drawMode, onDrawModeChange]);
|
|
|
|
|
|
- // Auto-hide controls
|
|
|
const resetHideTimer = useCallback(() => {
|
|
|
setShowControls(true);
|
|
|
clearTimeout(hideTimer.current);
|
|
|
- if (playing) {
|
|
|
- hideTimer.current = setTimeout(() => setShowControls(false), 3000);
|
|
|
- }
|
|
|
+ if (playing) hideTimer.current = setTimeout(() => setShowControls(false), 3000);
|
|
|
}, [playing]);
|
|
|
|
|
|
function stepFrame(dir: 1 | -1) {
|
|
|
@@ -263,13 +228,27 @@ export function VideoPlayer({
|
|
|
video.paused ? video.play() : video.pause();
|
|
|
}
|
|
|
|
|
|
- const handleVolume = (v: number) => {
|
|
|
+ function toggleMute() {
|
|
|
+ const video = videoRef.current;
|
|
|
+ if (!video) return;
|
|
|
+ if (muted || volume === 0) {
|
|
|
+ video.volume = 1;
|
|
|
+ setVolume(1);
|
|
|
+ setMuted(false);
|
|
|
+ } else {
|
|
|
+ video.volume = 0;
|
|
|
+ setVolume(0);
|
|
|
+ setMuted(true);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleVolumeSlider(v: number) {
|
|
|
const video = videoRef.current;
|
|
|
if (!video) return;
|
|
|
video.volume = v;
|
|
|
setVolume(v);
|
|
|
setMuted(v === 0);
|
|
|
- };
|
|
|
+ }
|
|
|
|
|
|
const handleSpeed = (rate: number) => {
|
|
|
const video = videoRef.current;
|
|
|
@@ -286,16 +265,19 @@ export function VideoPlayer({
|
|
|
onTimeUpdate(time);
|
|
|
};
|
|
|
|
|
|
+ // Volume icon: 0 bars = muted, 1 bar = low, 2 = med, 3 = high
|
|
|
+ const volBars = muted || volume === 0 ? 0 : volume <= 0.33 ? 1 : volume <= 0.66 ? 2 : 3;
|
|
|
+
|
|
|
return (
|
|
|
<div className="flex flex-col gap-0">
|
|
|
- {/* ── Video frame (no controls inside) ─────────────────── */}
|
|
|
+
|
|
|
+ {/* ── Video frame ──────────────────────────────────────────────── */}
|
|
|
<div
|
|
|
ref={containerRef}
|
|
|
className="relative bg-black rounded-xl overflow-hidden select-none group"
|
|
|
onMouseMove={resetHideTimer}
|
|
|
onMouseLeave={() => playing && setShowControls(false)}
|
|
|
>
|
|
|
- {/* Video */}
|
|
|
<video
|
|
|
ref={videoRef}
|
|
|
className="w-full block"
|
|
|
@@ -306,14 +288,12 @@ export function VideoPlayer({
|
|
|
playsInline
|
|
|
/>
|
|
|
|
|
|
- {/* Annotation display layer */}
|
|
|
<canvas
|
|
|
ref={displayCanvasRef}
|
|
|
className="absolute inset-0 z-[5] pointer-events-none"
|
|
|
style={{ display: drawMode ? 'none' : 'block' }}
|
|
|
/>
|
|
|
|
|
|
- {/* Annotation drawing layer */}
|
|
|
<AnnotationCanvas
|
|
|
isActive={drawMode}
|
|
|
tool={drawTool}
|
|
|
@@ -324,7 +304,7 @@ export function VideoPlayer({
|
|
|
onStrokeComplete={onStrokeComplete}
|
|
|
/>
|
|
|
|
|
|
- {/* Big play button overlay */}
|
|
|
+ {/* Big play button overlay — centered */}
|
|
|
{!playing && !drawMode && (
|
|
|
<button
|
|
|
className="absolute inset-0 flex items-center justify-center z-20"
|
|
|
@@ -333,10 +313,58 @@ export function VideoPlayer({
|
|
|
/>
|
|
|
)}
|
|
|
|
|
|
- {/* ── Floating speech bubble — inside video frame, overlays controls area ─ */}
|
|
|
+ {/* 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 */}
|
|
|
{activeComment && !drawMode && (
|
|
|
<div
|
|
|
- className="absolute bottom-2 left-2 right-2 z-30 pointer-events-none"
|
|
|
+ className="absolute bottom-2 left-2 right-2 z-30"
|
|
|
style={{ pointerEvents: 'auto' }}
|
|
|
>
|
|
|
<div className="flex justify-center">
|
|
|
@@ -352,14 +380,12 @@ export function VideoPlayer({
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
-
|
|
|
</div>
|
|
|
|
|
|
- {/* ── Controls AREA — outside the video frame ─────────── */}
|
|
|
- {/* Draw toolbar */}
|
|
|
+ {/* ── Draw toolbar (always above controls on mobile) ──────────── */}
|
|
|
{drawMode && (
|
|
|
- <div className="flex items-center gap-2 mt-2 px-1">
|
|
|
- <span className="text-xs text-[--text-muted]">Draw:</span>
|
|
|
+ <div className="flex flex-wrap items-center gap-2 mt-2 px-1">
|
|
|
+ <span className="text-xs shrink-0" style={{ color: 'var(--text-muted)' }}>Draw:</span>
|
|
|
{(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
|
|
|
<button
|
|
|
key={t}
|
|
|
@@ -371,7 +397,7 @@ export function VideoPlayer({
|
|
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
|
|
</button>
|
|
|
))}
|
|
|
- <div className="w-px h-5 bg-white/20 mx-1" />
|
|
|
+ <div className="w-px h-5 bg-white/20 shrink-0" />
|
|
|
{COLORS.map(c => (
|
|
|
<button
|
|
|
key={c.value}
|
|
|
@@ -383,10 +409,10 @@ export function VideoPlayer({
|
|
|
title={c.name}
|
|
|
/>
|
|
|
))}
|
|
|
- <div className="w-px h-5 bg-white/20 mx-1" />
|
|
|
+ <div className="w-px h-5 bg-white/20 shrink-0" />
|
|
|
<button
|
|
|
onClick={() => onDrawModeChange(false)}
|
|
|
- className="text-xs text-white/60 hover:text-white px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
|
|
|
+ className="text-xs text-white/60 hover:text-white px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors shrink-0"
|
|
|
>
|
|
|
Done
|
|
|
</button>
|
|
|
@@ -403,61 +429,60 @@ export function VideoPlayer({
|
|
|
onCommentClick={onCommentClick}
|
|
|
/>
|
|
|
|
|
|
- {/* Bottom controls row */}
|
|
|
- <div className="flex items-center gap-2 px-1 pb-1">
|
|
|
- {/* Play/Pause */}
|
|
|
+ {/* ── Bottom controls row ───────────────────────────────────────── */}
|
|
|
+ <div className="flex items-center gap-1 px-1 pb-2">
|
|
|
+
|
|
|
+ {/* Volume — icon only, tap cycles through levels */}
|
|
|
<button
|
|
|
- onClick={togglePlay}
|
|
|
- className="text-white/80 hover:text-white transition-colors"
|
|
|
- disabled={drawMode}
|
|
|
+ onClick={toggleMute}
|
|
|
+ className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors shrink-0"
|
|
|
+ style={{ color: 'rgba(255,255,255,0.7)' }}
|
|
|
+ title={muted || volume === 0 ? 'Unmute' : 'Mute'}
|
|
|
>
|
|
|
- {playing ? (
|
|
|
- <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
|
- <path d="M6 4h4v16H6zM14 4h4v16h-4z" />
|
|
|
- </svg>
|
|
|
- ) : (
|
|
|
- <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
|
- <path d="M8 5v14l11-7z" />
|
|
|
- </svg>
|
|
|
- )}
|
|
|
+ {/* 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">
|
|
|
+ {volBars === 0 ? (
|
|
|
+ <>
|
|
|
+ <path d="M11 5L6 9H2v6h4l5 4V5z" />
|
|
|
+ <line x1="23" y1="9" x2="17" y2="15" />
|
|
|
+ <line x1="17" y1="9" x2="23" y2="15" />
|
|
|
+ </>
|
|
|
+ ) : volBars === 1 ? (
|
|
|
+ <>
|
|
|
+ <path d="M11 5L6 9H2v6h4l5 4V5z" />
|
|
|
+ <path d="M15.54 8.46a5 5 0 010 7.07" />
|
|
|
+ </>
|
|
|
+ ) : volBars === 2 ? (
|
|
|
+ <>
|
|
|
+ <path d="M11 5L6 9H2v6h4l5 4V5z" />
|
|
|
+ <path d="M15.54 8.46a5 5 0 010 7.07" />
|
|
|
+ <path d="M19.07 4.93a10 10 0 010 14.14" />
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <path d="M11 5L6 9H2v6h4l5 4V5z" />
|
|
|
+ <path d="M15.54 8.46a5 5 0 010 7.07" />
|
|
|
+ <path d="M19.07 4.93a10 10 0 010 14.14" />
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </svg>
|
|
|
</button>
|
|
|
|
|
|
- {/* Frame step */}
|
|
|
- <button onClick={() => stepFrame(-1)} className="text-white/50 hover:text-white text-xs" title="Previous frame (U)">⏮</button>
|
|
|
- <button onClick={() => stepFrame(1)} className="text-white/50 hover:text-white text-xs" title="Next frame (I)">⏭</button>
|
|
|
-
|
|
|
- {/* Volume */}
|
|
|
- <button onClick={() => handleVolume(muted || volume === 0 ? 1 : 0)} className="text-white/70 hover:text-white">
|
|
|
- {muted || volume === 0 ? (
|
|
|
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707A1 1 0 0112 5v14a1 1 0 01-1.707.707L5.586 15z" />
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
|
|
- </svg>
|
|
|
- ) : (
|
|
|
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707A1 1 0 0112 5v14a1 1 0 01-1.707.707L5.586 15z" />
|
|
|
- </svg>
|
|
|
- )}
|
|
|
- </button>
|
|
|
+ {/* Desktop: volume slider (hidden on mobile) */}
|
|
|
<input
|
|
|
type="range" min={0} max={1} step={0.05}
|
|
|
value={muted ? 0 : volume}
|
|
|
- onChange={e => handleVolume(parseFloat(e.target.value))}
|
|
|
- className="w-14 h-1 accent-indigo-500"
|
|
|
+ onChange={e => handleVolumeSlider(parseFloat(e.target.value))}
|
|
|
+ className="w-14 h-1 accent-indigo-500 hidden md:block"
|
|
|
/>
|
|
|
|
|
|
- {/* Timecode */}
|
|
|
- <span className="text-xs text-white/50 font-mono ml-1">
|
|
|
- {formatTimecode(currentTime, fps, duration)} / {formatTimecode(duration, fps, duration)}
|
|
|
- </span>
|
|
|
-
|
|
|
<div className="flex-1" />
|
|
|
|
|
|
- {/* Speed */}
|
|
|
+ {/* Speed — desktop only */}
|
|
|
<select
|
|
|
value={playbackRate}
|
|
|
onChange={e => handleSpeed(parseFloat(e.target.value))}
|
|
|
- className="bg-transparent text-xs text-white/70 border border-white/25 rounded px-1.5 py-0.5 cursor-pointer hover:border-white/50"
|
|
|
+ className="bg-transparent text-xs text-white/70 border border-white/25 rounded px-1.5 py-0.5 cursor-pointer hover:border-white/50 hidden md:block"
|
|
|
>
|
|
|
{[0.25, 0.5, 0.75, 1, 1.25, 1.5, 2].map(r => (
|
|
|
<option key={r} value={r} className="text-black">{r}x</option>
|
|
|
@@ -467,16 +492,10 @@ export function VideoPlayer({
|
|
|
{/* Draw mode toggle */}
|
|
|
<button
|
|
|
onClick={() => {
|
|
|
- if (!drawMode) {
|
|
|
- videoRef.current?.pause();
|
|
|
- onDrawModeChange(true);
|
|
|
- } else {
|
|
|
- onDrawModeChange(false);
|
|
|
- }
|
|
|
+ if (!drawMode) { videoRef.current?.pause(); onDrawModeChange(true); }
|
|
|
+ else { onDrawModeChange(false); }
|
|
|
}}
|
|
|
- className={`p-1.5 rounded transition-colors ${
|
|
|
- drawMode ? 'bg-indigo-600 text-white' : 'bg-white/10 text-white/70 hover:bg-white/20'
|
|
|
- }`}
|
|
|
+ className={`p-1.5 rounded transition-colors ${drawMode ? 'bg-indigo-600 text-white' : 'bg-white/10 text-white/70 hover:bg-white/20'}`}
|
|
|
title="Toggle draw mode (C)"
|
|
|
>
|
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
@@ -485,7 +504,12 @@ export function VideoPlayer({
|
|
|
</button>
|
|
|
|
|
|
{/* Fullscreen */}
|
|
|
- <button onClick={toggleFullscreen} className="text-white/70 hover:text-white" title="Fullscreen (F)">
|
|
|
+ <button
|
|
|
+ onClick={toggleFullscreen}
|
|
|
+ className="w-8 h-8 flex items-center justify-center rounded-lg transition-colors"
|
|
|
+ style={{ color: 'rgba(255,255,255,0.7)' }}
|
|
|
+ title="Fullscreen (F)"
|
|
|
+ >
|
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
{fullscreen ? (
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
|
|
|
@@ -497,4 +521,4 @@ export function VideoPlayer({
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
-}
|
|
|
+}
|