|
@@ -9,31 +9,115 @@ interface Props {
|
|
|
currentTime: number;
|
|
currentTime: number;
|
|
|
fps: number;
|
|
fps: number;
|
|
|
comments: Comment[];
|
|
comments: Comment[];
|
|
|
|
|
+ /** Low-res video source for instant thumbnail scrubbing.
|
|
|
|
|
+ * Hover shows live frame from this video; main video stays smooth. */
|
|
|
|
|
+ thumbnailSrc?: string;
|
|
|
|
|
+ thumbnailMimeType?: string;
|
|
|
|
|
+ /** Main video element — used for background pre-seek cache during hover. */
|
|
|
|
|
+ mainVideoRef?: React.RefObject<HTMLVideoElement | null>;
|
|
|
onSeek: (time: number) => void;
|
|
onSeek: (time: number) => void;
|
|
|
onCommentClick: (comment: Comment) => void;
|
|
onCommentClick: (comment: Comment) => void;
|
|
|
|
|
+ /** Called when scrubbing starts/stops so VideoPlayer can pause the main video */
|
|
|
|
|
+ onScrubStart?: () => void;
|
|
|
|
|
+ onScrubEnd?: () => void;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export function Timeline({ duration, currentTime, fps, comments, onSeek, onCommentClick }: Props) {
|
|
|
|
|
|
|
+export function Timeline({
|
|
|
|
|
+ duration,
|
|
|
|
|
+ currentTime,
|
|
|
|
|
+ fps,
|
|
|
|
|
+ comments,
|
|
|
|
|
+ thumbnailSrc,
|
|
|
|
|
+ thumbnailMimeType,
|
|
|
|
|
+ mainVideoRef,
|
|
|
|
|
+ onSeek,
|
|
|
|
|
+ onCommentClick,
|
|
|
|
|
+ onScrubStart,
|
|
|
|
|
+ onScrubEnd,
|
|
|
|
|
+}: Props) {
|
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
+ // Hidden video used for thumbnail frame capture (no audio, no UI)
|
|
|
|
|
+ const scrubVideoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
|
+ // Canvas that renders the current scrubber frame into the thumbnail tooltip
|
|
|
|
|
+ const scrubCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
+ // RAF handle — drives smooth canvas updates from the scrubber video
|
|
|
|
|
+ const scrubRafRef = useRef<number | null>(null);
|
|
|
|
|
|
|
|
// ── Drag state ─────────────────────────────────────────────────────────────
|
|
// ── Drag state ─────────────────────────────────────────────────────────────
|
|
|
- // draggingRef: controls video seeks during drag (written by handlers, read by effect)
|
|
|
|
|
const draggingRef = useRef(false);
|
|
const draggingRef = useRef(false);
|
|
|
- // isDragging: React state — controls visual active state (scrubber scale, tooltip)
|
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
- // draggedTime: tracks the visual position of the scrubber during drag, decoupled from
|
|
|
|
|
- // the actual video time so the scrubber never lags behind the cursor
|
|
|
|
|
const [draggedTime, setDraggedTime] = useState(currentTime);
|
|
const [draggedTime, setDraggedTime] = useState(currentTime);
|
|
|
- // tooltipX: CSS left position (%) for tooltip positioning during drag
|
|
|
|
|
const [tooltipX, setTooltipX] = useState(0);
|
|
const [tooltipX, setTooltipX] = useState(0);
|
|
|
|
|
|
|
|
- // ── Sync display time when NOT dragging ──────────────────────────────────
|
|
|
|
|
|
|
+ // ── Hover / thumbnail state ────────────────────────────────────────────────
|
|
|
|
|
+ const [isHovering, setIsHovering] = useState(false);
|
|
|
|
|
+ const [hoverTime, setHoverTime] = useState(0);
|
|
|
|
|
+ const [thumbnailsReady, setThumbnailsReady] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Sync display time when NOT dragging ───────────────────────────────────
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (draggingRef.current) return;
|
|
if (draggingRef.current) return;
|
|
|
setDraggedTime(currentTime);
|
|
setDraggedTime(currentTime);
|
|
|
}, [currentTime]);
|
|
}, [currentTime]);
|
|
|
|
|
|
|
|
- // ── Cursor → time ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
+ // ── Scrubber video: initialise HLS or direct src ─────────────────────────
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const video = scrubVideoRef.current;
|
|
|
|
|
+ if (!video || !thumbnailSrc) return;
|
|
|
|
|
+
|
|
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
|
|
|
+ const Hls = require('hls.js');
|
|
|
|
|
+ const isHls =
|
|
|
|
|
+ thumbnailMimeType === 'application/x-mpegURL' || thumbnailSrc.endsWith('.m3u8');
|
|
|
|
|
+
|
|
|
|
|
+ if (isHls && Hls.isSupported()) {
|
|
|
|
|
+ const hls = new Hls({ enableWorker: false, lowLatencyMode: true });
|
|
|
|
|
+ hls.loadSource(thumbnailSrc);
|
|
|
|
|
+ hls.attachMedia(video);
|
|
|
|
|
+ hls.on(Hls.Events.MANIFEST_PARSED, () => setThumbnailsReady(true));
|
|
|
|
|
+ return () => { hls.destroy(); };
|
|
|
|
|
+ } else {
|
|
|
|
|
+ video.src = thumbnailSrc;
|
|
|
|
|
+ video.addEventListener('loadeddata', () => setThumbnailsReady(true), { once: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [thumbnailSrc, thumbnailMimeType]);
|
|
|
|
|
+
|
|
|
|
|
+ // ── RAF loop: copy scrubber video frame → canvas ─────────────────────────
|
|
|
|
|
+ const updateScrubFrame = useCallback(() => {
|
|
|
|
|
+ const video = scrubVideoRef.current;
|
|
|
|
|
+ const canvas = scrubCanvasRef.current;
|
|
|
|
|
+ if (video && canvas && thumbnailsReady) {
|
|
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
|
|
+ if (ctx && video.readyState >= 2 && video.videoWidth > 0) {
|
|
|
|
|
+ canvas.width = video.videoWidth;
|
|
|
|
|
+ canvas.height = video.videoHeight;
|
|
|
|
|
+ ctx.drawImage(video, 0, 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isHovering || isDragging) {
|
|
|
|
|
+ scrubRafRef.current = requestAnimationFrame(updateScrubFrame);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [thumbnailsReady, isHovering, isDragging]);
|
|
|
|
|
+
|
|
|
|
|
+ // Start/stop RAF when hover or drag changes
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if ((isHovering || isDragging) && thumbnailSrc && thumbnailsReady) {
|
|
|
|
|
+ scrubRafRef.current = requestAnimationFrame(updateScrubFrame);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (scrubRafRef.current !== null) {
|
|
|
|
|
+ cancelAnimationFrame(scrubRafRef.current);
|
|
|
|
|
+ scrubRafRef.current = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ if (scrubRafRef.current !== null) {
|
|
|
|
|
+ cancelAnimationFrame(scrubRafRef.current);
|
|
|
|
|
+ scrubRafRef.current = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ }, [isHovering, isDragging, thumbnailSrc, thumbnailsReady, updateScrubFrame]);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Cursor → time / percent ────────────────────────────────────────────────
|
|
|
const cursorToTime = useCallback((clientX: number): number | null => {
|
|
const cursorToTime = useCallback((clientX: number): number | null => {
|
|
|
if (!trackRef.current) return null;
|
|
if (!trackRef.current) return null;
|
|
|
const rect = trackRef.current.getBoundingClientRect();
|
|
const rect = trackRef.current.getBoundingClientRect();
|
|
@@ -47,82 +131,170 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
|
|
|
return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
|
}, []);
|
|
}, []);
|
|
|
|
|
|
|
|
- // ── Core seek — updates display + video ──────────────────────────────────
|
|
|
|
|
- const seekTo = useCallback((t: number) => {
|
|
|
|
|
- const clamped = Math.max(0, Math.min(duration, t));
|
|
|
|
|
- setDraggedTime(clamped);
|
|
|
|
|
- onSeek(clamped);
|
|
|
|
|
- }, [duration, onSeek]);
|
|
|
|
|
|
|
+ // ── Seek helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /** Seek the main video immediately — used on mousedown / drag. */
|
|
|
|
|
+ const seekMainVideo = useCallback((t: number) => {
|
|
|
|
|
+ const video = mainVideoRef?.current ?? null;
|
|
|
|
|
+ if (!video) return;
|
|
|
|
|
+ video.currentTime = t;
|
|
|
|
|
+ onSeek(t);
|
|
|
|
|
+ }, [mainVideoRef, onSeek]);
|
|
|
|
|
+
|
|
|
|
|
+ /** Seek the hidden scrubber/thumbnail video to a time (instant, no debounce). */
|
|
|
|
|
+ const scrubVideoSeek = useCallback((t: number) => {
|
|
|
|
|
+ const video = scrubVideoRef.current;
|
|
|
|
|
+ if (!video || !thumbnailsReady) return;
|
|
|
|
|
+ // Skip if we're already within a quarter-second — avoids redundant seeks
|
|
|
|
|
+ if (Math.abs(video.currentTime - t) > 0.25) {
|
|
|
|
|
+ video.currentTime = t;
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [thumbnailsReady]);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Background pre-seek cache ────────────────────────────────────────────
|
|
|
|
|
+ // When the user lingers on a timecode during hover, pre-seek the main video
|
|
|
|
|
+ // so the first real seek feels instant.
|
|
|
|
|
+ const cacheDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
+ const cachedTimeRef = useRef<number>(-1);
|
|
|
|
|
+
|
|
|
|
|
+ const scheduleCache = useCallback((t: number) => {
|
|
|
|
|
+ if (cacheDebounceRef.current) clearTimeout(cacheDebounceRef.current);
|
|
|
|
|
+ cacheDebounceRef.current = setTimeout(() => {
|
|
|
|
|
+ if (cachedTimeRef.current !== t) {
|
|
|
|
|
+ cachedTimeRef.current = t;
|
|
|
|
|
+ const video = mainVideoRef?.current ?? null;
|
|
|
|
|
+ if (video) video.currentTime = t;
|
|
|
|
|
+ }
|
|
|
|
|
+ cacheDebounceRef.current = null;
|
|
|
|
|
+ }, 400);
|
|
|
|
|
+ }, [mainVideoRef]);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Drag handlers ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
- // ── Mouse handlers (desktop) ─────────────────────────────────────────────
|
|
|
|
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
|
if (e.button !== 0) return;
|
|
if (e.button !== 0) return;
|
|
|
e.preventDefault();
|
|
e.preventDefault();
|
|
|
-
|
|
|
|
|
- // Reset stale drag state
|
|
|
|
|
- draggingRef.current = false;
|
|
|
|
|
- setIsDragging(false);
|
|
|
|
|
-
|
|
|
|
|
- // Begin drag
|
|
|
|
|
draggingRef.current = true;
|
|
draggingRef.current = true;
|
|
|
setIsDragging(true);
|
|
setIsDragging(true);
|
|
|
|
|
+ onScrubStart?.();
|
|
|
|
|
|
|
|
const t = cursorToTime(e.clientX);
|
|
const t = cursorToTime(e.clientX);
|
|
|
if (t === null) return;
|
|
if (t === null) return;
|
|
|
- seekTo(t);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // Scrubbing: update playhead + thumbnail + seek main video — all instantly
|
|
|
|
|
+ setDraggedTime(t);
|
|
|
setTooltipX(cursorToPercent(e.clientX));
|
|
setTooltipX(cursorToPercent(e.clientX));
|
|
|
|
|
+ scrubVideoSeek(t);
|
|
|
|
|
+ seekMainVideo(t);
|
|
|
|
|
+
|
|
|
|
|
+ // Cancel any pending background cache since we're actively scrubbing
|
|
|
|
|
+ if (cacheDebounceRef.current) {
|
|
|
|
|
+ clearTimeout(cacheDebounceRef.current);
|
|
|
|
|
+ cacheDebounceRef.current = null;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // Capture pointer so mouseup always fires even if cursor leaves the track
|
|
|
|
|
trackRef.current?.setPointerCapture((e as unknown as React.PointerEvent).pointerId);
|
|
trackRef.current?.setPointerCapture((e as unknown as React.PointerEvent).pointerId);
|
|
|
- }, [cursorToTime, cursorToPercent, seekTo]);
|
|
|
|
|
|
|
+ }, [cursorToTime, cursorToPercent, scrubVideoSeek, seekMainVideo, onScrubStart]);
|
|
|
|
|
|
|
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
|
if (!draggingRef.current) return;
|
|
if (!draggingRef.current) return;
|
|
|
const t = cursorToTime(e.clientX);
|
|
const t = cursorToTime(e.clientX);
|
|
|
if (t === null) return;
|
|
if (t === null) return;
|
|
|
- seekTo(t);
|
|
|
|
|
|
|
+ setDraggedTime(t);
|
|
|
setTooltipX(cursorToPercent(e.clientX));
|
|
setTooltipX(cursorToPercent(e.clientX));
|
|
|
- }, [cursorToTime, cursorToPercent, seekTo]);
|
|
|
|
|
|
|
+ scrubVideoSeek(t);
|
|
|
|
|
+ seekMainVideo(t);
|
|
|
|
|
+ }, [cursorToTime, cursorToPercent, scrubVideoSeek, seekMainVideo]);
|
|
|
|
|
|
|
|
const handleMouseUp = useCallback((e: MouseEvent) => {
|
|
const handleMouseUp = useCallback((e: MouseEvent) => {
|
|
|
if (!draggingRef.current) return;
|
|
if (!draggingRef.current) return;
|
|
|
draggingRef.current = false;
|
|
draggingRef.current = false;
|
|
|
setIsDragging(false);
|
|
setIsDragging(false);
|
|
|
|
|
+ onScrubEnd?.();
|
|
|
|
|
+ }, [onScrubEnd]);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Hover handlers — thumbnail only, NO playhead move, NO main video seek ─
|
|
|
|
|
+ const handleTrackMouseEnter = useCallback(() => {
|
|
|
|
|
+ setIsHovering(true);
|
|
|
|
|
+ cachedTimeRef.current = -1;
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ const handleTrackMouseLeave = useCallback(() => {
|
|
|
|
|
+ setIsHovering(false);
|
|
|
|
|
+ if (cacheDebounceRef.current) {
|
|
|
|
|
+ clearTimeout(cacheDebounceRef.current);
|
|
|
|
|
+ cacheDebounceRef.current = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ const handleTrackMouseMove = useCallback((e: React.MouseEvent) => {
|
|
|
|
|
+ if (isDragging) {
|
|
|
|
|
+ // Event bubbles up from track → wrapper when dragging on the track bar.
|
|
|
|
|
+ // draggingRef is fresh (not stale) so window handleMouseMove processes it correctly.
|
|
|
|
|
+ // We still need to prevent default so the wrapper doesn't interfere.
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Guard: cursor must be within the track's bounding box.
|
|
|
|
|
+ // The wrapper's padding extends the hit area, but cursorToTime clamps to [0,1]
|
|
|
|
|
+ // using the track's rect — without this check, padding area → t ≈ 0 or t ≈ duration.
|
|
|
|
|
+ const rect = trackRef.current?.getBoundingClientRect();
|
|
|
|
|
+ if (!rect) return;
|
|
|
|
|
+ const { left, right } = rect;
|
|
|
|
|
+ if (e.clientX < left || e.clientX > right) return;
|
|
|
|
|
+
|
|
|
const t = cursorToTime(e.clientX);
|
|
const t = cursorToTime(e.clientX);
|
|
|
- if (t !== null) seekTo(t);
|
|
|
|
|
- }, [cursorToTime, seekTo]);
|
|
|
|
|
|
|
+ if (t === null) return;
|
|
|
|
|
|
|
|
- // ── Touch handlers (mobile) ───────────────────────────────────────────────
|
|
|
|
|
|
|
+ // Thumbnail tooltip: update timecode — NO playhead move, NO main video seek
|
|
|
|
|
+ setHoverTime(t);
|
|
|
|
|
+ setTooltipX(cursorToPercent(e.clientX));
|
|
|
|
|
+
|
|
|
|
|
+ // Seek the hidden thumbnail video so the tooltip shows the right frame
|
|
|
|
|
+ scrubVideoSeek(t);
|
|
|
|
|
+
|
|
|
|
|
+ // Background cache: if the user lingers at roughly the same position,
|
|
|
|
|
+ // pre-seek the main video so a future click feels instant.
|
|
|
|
|
+ scheduleCache(t);
|
|
|
|
|
+ }, [isDragging, cursorToTime, cursorToPercent, scrubVideoSeek, scheduleCache]);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Touch handlers ───────────────────────────────────────────────────────
|
|
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
|
if (e.touches.length !== 1) return;
|
|
if (e.touches.length !== 1) return;
|
|
|
const touch = e.touches[0];
|
|
const touch = e.touches[0];
|
|
|
draggingRef.current = true;
|
|
draggingRef.current = true;
|
|
|
setIsDragging(true);
|
|
setIsDragging(true);
|
|
|
|
|
+ onScrubStart?.();
|
|
|
const t = cursorToTime(touch.clientX);
|
|
const t = cursorToTime(touch.clientX);
|
|
|
- if (t !== null) seekTo(t);
|
|
|
|
|
- setTooltipX(cursorToPercent(touch.clientX));
|
|
|
|
|
- }, [cursorToTime, cursorToPercent, seekTo]);
|
|
|
|
|
|
|
+ if (t !== null) {
|
|
|
|
|
+ setDraggedTime(t);
|
|
|
|
|
+ setTooltipX(cursorToPercent(touch.clientX));
|
|
|
|
|
+ scrubVideoSeek(t);
|
|
|
|
|
+ seekMainVideo(t);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [cursorToTime, cursorToPercent, scrubVideoSeek, seekMainVideo, onScrubStart]);
|
|
|
|
|
|
|
|
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
|
if (!draggingRef.current || e.touches.length !== 1) return;
|
|
if (!draggingRef.current || e.touches.length !== 1) return;
|
|
|
const touch = e.touches[0];
|
|
const touch = e.touches[0];
|
|
|
const t = cursorToTime(touch.clientX);
|
|
const t = cursorToTime(touch.clientX);
|
|
|
- if (t !== null) seekTo(t);
|
|
|
|
|
- setTooltipX(cursorToPercent(touch.clientX));
|
|
|
|
|
- }, [cursorToTime, cursorToPercent, seekTo]);
|
|
|
|
|
|
|
+ if (t !== null) {
|
|
|
|
|
+ setDraggedTime(t);
|
|
|
|
|
+ setTooltipX(cursorToPercent(touch.clientX));
|
|
|
|
|
+ scrubVideoSeek(t);
|
|
|
|
|
+ seekMainVideo(t);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [cursorToTime, cursorToPercent, scrubVideoSeek, seekMainVideo]);
|
|
|
|
|
|
|
|
- const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
|
|
|
|
|
|
+ const handleTouchEnd = useCallback(() => {
|
|
|
if (!draggingRef.current) return;
|
|
if (!draggingRef.current) return;
|
|
|
draggingRef.current = false;
|
|
draggingRef.current = false;
|
|
|
setIsDragging(false);
|
|
setIsDragging(false);
|
|
|
- const touch = e.changedTouches[0];
|
|
|
|
|
- if (touch) {
|
|
|
|
|
- const t = cursorToTime(touch.clientX);
|
|
|
|
|
- if (t !== null) seekTo(t);
|
|
|
|
|
- }
|
|
|
|
|
- }, [cursorToTime, seekTo]);
|
|
|
|
|
|
|
+ onScrubEnd?.();
|
|
|
|
|
+ }, [onScrubEnd]);
|
|
|
|
|
|
|
|
- // ── Global mouse listeners (desktop drag) ─────────────────────────────────
|
|
|
|
|
|
|
+ // ── Global mouse listeners ───────────────────────────────────────────────
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
@@ -134,10 +306,32 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
|
|
|
|
|
|
|
|
// ── Scrubber position ─────────────────────────────────────────────────────
|
|
// ── Scrubber position ─────────────────────────────────────────────────────
|
|
|
const progress = duration > 0 ? (draggedTime / duration) * 100 : 0;
|
|
const progress = duration > 0 ? (draggedTime / duration) * 100 : 0;
|
|
|
|
|
+ // Show hover timecode in tooltip during hover; drag time during scrubbing
|
|
|
|
|
+ const tooltipTime = isDragging ? draggedTime : (isHovering ? hoverTime : currentTime);
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<div className="relative py-3 select-none">
|
|
<div className="relative py-3 select-none">
|
|
|
|
|
|
|
|
|
|
+ {/* Hidden video for thumbnail frame capture (no audio, no UI) */}
|
|
|
|
|
+ {thumbnailSrc && (
|
|
|
|
|
+ <video
|
|
|
|
|
+ ref={scrubVideoRef}
|
|
|
|
|
+ className="sr-only"
|
|
|
|
|
+ aria-hidden="true"
|
|
|
|
|
+ tabIndex={-1}
|
|
|
|
|
+ muted
|
|
|
|
|
+ preload="auto"
|
|
|
|
|
+ playsInline
|
|
|
|
|
+ style={{
|
|
|
|
|
+ position: 'absolute',
|
|
|
|
|
+ width: 1,
|
|
|
|
|
+ height: 1,
|
|
|
|
|
+ opacity: 0,
|
|
|
|
|
+ pointerEvents: 'none',
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{/* Comment tick marks */}
|
|
{/* Comment tick marks */}
|
|
|
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-0 pointer-events-none">
|
|
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-0 pointer-events-none">
|
|
|
{comments.map(comment => {
|
|
{comments.map(comment => {
|
|
@@ -162,74 +356,115 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
|
|
|
})}
|
|
})}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* ── Seek bar ──────────────────────────────────────────────────── */}
|
|
|
|
|
|
|
+ {/* ── Wide hover hit-area wrapper ──────────────────────────────────── */}
|
|
|
|
|
+ {/* Padding extends the hover zone above/below the thin seek bar so users
|
|
|
|
|
+ don't lose the tooltip when their cursor drifts off the narrow track. */}
|
|
|
<div
|
|
<div
|
|
|
- ref={trackRef}
|
|
|
|
|
- className="relative h-2 md:h-1.5 bg-white/10 rounded-full cursor-pointer"
|
|
|
|
|
- onMouseDown={handleMouseDown}
|
|
|
|
|
- onTouchStart={handleTouchStart}
|
|
|
|
|
- onTouchMove={handleTouchMove}
|
|
|
|
|
- onTouchEnd={handleTouchEnd}
|
|
|
|
|
- style={{ touchAction: 'none' }}
|
|
|
|
|
|
|
+ className="relative"
|
|
|
|
|
+ style={{ paddingTop: 24, paddingBottom: 32, marginTop: -24 }}
|
|
|
|
|
+ onMouseEnter={handleTrackMouseEnter}
|
|
|
|
|
+ onMouseLeave={handleTrackMouseLeave}
|
|
|
|
|
+ onMouseMove={handleTrackMouseMove}
|
|
|
>
|
|
>
|
|
|
- {/* Played / scrubbed portion */}
|
|
|
|
|
- <div
|
|
|
|
|
- className="absolute left-0 top-0 h-full bg-indigo-500 rounded-full will-change-width"
|
|
|
|
|
- style={{ width: `${progress}%` }}
|
|
|
|
|
- />
|
|
|
|
|
-
|
|
|
|
|
- {/* Scrubber — active state: indigo glow + scale up */}
|
|
|
|
|
|
|
+ {/* ── Seek bar ──────────────────────────────────────────────── */}
|
|
|
|
|
+ {/* NOTE: the visual playhead (draggedTime) only moves on drag/mousedown.
|
|
|
|
|
+ During hover the playhead stays at currentTime — no misleading movement. */}
|
|
|
<div
|
|
<div
|
|
|
- className="absolute top-1/2 -translate-y-1/2 rounded-full will-change-transform"
|
|
|
|
|
- style={{
|
|
|
|
|
- left: `calc(${progress}% - 8px)`,
|
|
|
|
|
- width: isDragging ? '18px' : '14px',
|
|
|
|
|
- height: isDragging ? '18px' : '14px',
|
|
|
|
|
- marginLeft: isDragging ? '-2px' : '0px',
|
|
|
|
|
- marginTop: isDragging ? '-2px' : '0px',
|
|
|
|
|
- background: 'white',
|
|
|
|
|
- border: '2px solid #818CF8',
|
|
|
|
|
- boxShadow: isDragging
|
|
|
|
|
- ? '0 0 0 4px rgba(99,102,241,0.25), 0 0 12px rgba(99,102,241,0.4)'
|
|
|
|
|
- : '0 1px 4px rgba(0,0,0,0.3)',
|
|
|
|
|
- transition: isDragging ? 'none' : 'width 0.12s, height 0.12s, margin 0.12s, box-shadow 0.12s',
|
|
|
|
|
- }}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- {/* ── Timecode tooltip — appears above scrubber during drag ───────── */}
|
|
|
|
|
- {isDragging && (
|
|
|
|
|
- <div
|
|
|
|
|
- className="absolute bottom-full mb-2 px-2 py-1 rounded-lg text-xs font-mono pointer-events-none whitespace-nowrap z-10"
|
|
|
|
|
- style={{
|
|
|
|
|
- left: `clamp(40px, ${tooltipX}%, calc(100% - 40px))`,
|
|
|
|
|
- transform: 'translateX(-50%)',
|
|
|
|
|
- background: 'rgba(10,11,20,0.95)',
|
|
|
|
|
- border: '1px solid rgba(99,102,241,0.40)',
|
|
|
|
|
- color: '#A5B4FC',
|
|
|
|
|
- backdropFilter: 'blur(8px)',
|
|
|
|
|
- }}
|
|
|
|
|
|
|
+ ref={trackRef}
|
|
|
|
|
+ className="relative h-2 md:h-1.5 bg-white/10 rounded-full cursor-pointer"
|
|
|
|
|
+ onMouseDown={handleMouseDown}
|
|
|
|
|
+ onTouchStart={handleTouchStart}
|
|
|
|
|
+ onTouchMove={handleTouchMove}
|
|
|
|
|
+ onTouchEnd={handleTouchEnd}
|
|
|
|
|
+ style={{ touchAction: 'none' }}
|
|
|
>
|
|
>
|
|
|
- {formatTimecode(draggedTime, fps, duration)}
|
|
|
|
|
- {/* Tooltip pointer — points down to scrubber */}
|
|
|
|
|
|
|
+ {/* Played / scrubbed portion */}
|
|
|
<div
|
|
<div
|
|
|
|
|
+ className="absolute left-0 top-0 h-full bg-indigo-500 rounded-full will-change-width"
|
|
|
|
|
+ style={{ width: `${progress}%` }}
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ {/* Scrubber thumb */}
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="absolute top-1/2 -translate-y-1/2 rounded-full will-change-transform"
|
|
|
style={{
|
|
style={{
|
|
|
- position: 'absolute',
|
|
|
|
|
- top: '100%',
|
|
|
|
|
- left: '50%',
|
|
|
|
|
- transform: 'translateX(-50%)',
|
|
|
|
|
- width: 0,
|
|
|
|
|
- height: 0,
|
|
|
|
|
- borderLeft: '5px solid transparent',
|
|
|
|
|
- borderRight: '5px solid transparent',
|
|
|
|
|
- borderTop: '5px solid rgba(99,102,241,0.40)',
|
|
|
|
|
|
|
+ left: `calc(${progress}% - 8px)`,
|
|
|
|
|
+ width: isDragging ? '18px' : '14px',
|
|
|
|
|
+ height: isDragging ? '18px' : '14px',
|
|
|
|
|
+ marginLeft: isDragging ? '-2px' : '0px',
|
|
|
|
|
+ marginTop: isDragging ? '-2px' : '0px',
|
|
|
|
|
+ background: 'white',
|
|
|
|
|
+ border: '2px solid #818CF8',
|
|
|
|
|
+ boxShadow: isDragging
|
|
|
|
|
+ ? '0 0 0 4px rgba(99,102,241,0.25), 0 0 12px rgba(99,102,241,0.4)'
|
|
|
|
|
+ : '0 1px 4px rgba(0,0,0,0.3)',
|
|
|
|
|
+ transition: isDragging ? 'none' : 'width 0.12s, height 0.12s, margin 0.12s, box-shadow 0.12s',
|
|
|
}}
|
|
}}
|
|
|
/>
|
|
/>
|
|
|
</div>
|
|
</div>
|
|
|
- )}
|
|
|
|
|
|
|
|
|
|
- {/* Timecode display */}
|
|
|
|
|
- <div className="flex justify-between mt-1.5 text-xs text-gray-400 font-mono">
|
|
|
|
|
|
|
+ {/* ── Thumbnail tooltip ─────────────────────────────────────── */}
|
|
|
|
|
+ {(isDragging || isHovering) && (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="absolute bottom-full mb-2 pointer-events-none z-10 flex flex-col items-center"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ left: `clamp(80px, ${tooltipX}%, calc(100% - 80px))`,
|
|
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* Thumbnail frame — live canvas from scrubber video */}
|
|
|
|
|
+ {thumbnailSrc && (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="rounded-lg overflow-hidden border animate-scale-in"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ width: 160,
|
|
|
|
|
+ height: 90,
|
|
|
|
|
+ background: '#000',
|
|
|
|
|
+ border: '1px solid rgba(99,102,241,0.40)',
|
|
|
|
|
+ boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <canvas
|
|
|
|
|
+ ref={scrubCanvasRef}
|
|
|
|
|
+ className="w-full h-full object-cover"
|
|
|
|
|
+ style={{ display: thumbnailsReady ? 'block' : 'none' }}
|
|
|
|
|
+ />
|
|
|
|
|
+ {/* Loading skeleton while video initialises */}
|
|
|
|
|
+ {!thumbnailsReady && (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="w-full h-full flex items-center justify-center"
|
|
|
|
|
+ style={{ background: '#111' }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="w-4 h-4 rounded-full animate-spin"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ border: '2px solid rgba(99,102,241,0.3)',
|
|
|
|
|
+ borderTopColor: '#818CF8',
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Timecode label */}
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="mt-1 px-2 py-1 rounded-lg text-xs font-mono whitespace-nowrap"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ background: 'rgba(10,11,20,0.95)',
|
|
|
|
|
+ border: '1px solid rgba(99,102,241,0.40)',
|
|
|
|
|
+ color: '#A5B4FC',
|
|
|
|
|
+ backdropFilter: 'blur(8px)',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {formatTimecode(tooltipTime, fps, duration)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* ── Timecode display ───────────────────────────────────────────── */}
|
|
|
|
|
+ <div className="flex justify-between text-xs text-gray-400 font-mono" style={{ paddingLeft: 0, paddingRight: 0 }}>
|
|
|
<span>{formatTimecode(draggedTime, fps, duration)}</span>
|
|
<span>{formatTimecode(draggedTime, fps, duration)}</span>
|
|
|
<span>{formatTimecode(duration, fps, duration)}</span>
|
|
<span>{formatTimecode(duration, fps, duration)}</span>
|
|
|
</div>
|
|
</div>
|