Эх сурвалжийг харах

fix: volume mute, timeline drag, seek responsiveness, transcode queue

- toggleMute: toggle video.muted directly so it actually works on mobile
- Timeline: use setPointerCapture so mouseup always fires even when
  pointer leaves element; clean up stale drag state on pointerdown
- VideoPlayer: add seeked event listener so currentTime + annotations
  update immediately on seek/step-frame, not only on next rVFC tick
- Worker: recursive poll() after every job so the next queued job
  starts immediately instead of waiting for the next 5s interval tick

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev 1 сар өмнө
parent
commit
004ddd4534

+ 33 - 31
packages/api/src/worker/index.js

@@ -201,41 +201,43 @@ async function processJob(asset) {
   }
 }
 
-/** ── Poll loop ─────────────────────────────────────────────────────────── */
+/** ── Claim one job (atomic) ─────────────────────────────────────────────── */
+async function claimOneJob() {
+  const result = await prisma.$executeRaw`
+    UPDATE "Asset"
+    SET    "transcodeStatus" = 'PROCESSING',
+           "transcodeProgress" = 0,
+           "updatedAt" = NOW()
+    WHERE  id = (
+      SELECT id FROM "Asset"
+      WHERE  "transcodeStatus" = 'PENDING'
+        AND  "transcodePaused" = false
+      ORDER  BY "createdAt" ASC
+      LIMIT  1
+      FOR    UPDATE SKIP LOCKED
+    )
+    RETURNING id, "filePath", "transcodeStatus", "transcodePaused"
+  `;
+
+  if (!result || result === 0) return null;
+
+  // Re-fetch the claimed asset (result doesn't return full row with $executeRaw)
+  return prisma.asset.findFirst({
+    where: { transcodeStatus: 'PROCESSING' },
+    orderBy: { updatedAt: 'asc' },
+    take: 1,
+  });
+}
+
+/** ── Poll loop (runs on interval AND after every job) ───────────────────── */
 async function poll() {
   try {
-    // Atomically claim one PENDING job that is NOT paused
-    // Prisma's updateMany returns the count; we use raw SQL for the atomic claim
-    const result = await prisma.$executeRaw`
-      UPDATE "Asset"
-      SET    "transcodeStatus" = 'PROCESSING',
-             "transcodeProgress" = 0,
-             "updatedAt" = NOW()
-      WHERE  id = (
-        SELECT id FROM "Asset"
-        WHERE  "transcodeStatus" = 'PENDING'
-          AND  "transcodePaused" = false
-        ORDER  BY "createdAt" ASC
-        LIMIT  1
-        FOR    UPDATE SKIP LOCKED
-      )
-      RETURNING id, "filePath", "transcodeStatus", "transcodePaused"
-    `;
-
-    if (!result || result === 0) {
-      return; // No jobs
-    }
-
-    // Re-fetch the claimed asset (result doesn't return full row with $executeRaw)
-    const claimed = await prisma.asset.findFirst({
-      where: { transcodeStatus: 'PROCESSING' },
-      orderBy: { updatedAt: 'asc' },
-      take: 1,
-    });
-
+    const claimed = await claimOneJob();
     if (!claimed) return;
-
     await processJob(claimed);
+    // Immediately poll again — don't wait for the next interval tick
+    // This prevents the 5-second gap between back-to-back jobs
+    poll().catch(err => console.error('[worker] Recursive poll error:', err.message));
   } catch (err) {
     console.error('[worker] Poll error:', err.message);
   }

+ 15 - 3
src/components/video-player/Timeline.tsx

@@ -56,7 +56,6 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
     draggingRef.current = false;
     document.body.style.userSelect = '';
     document.body.style.cursor = '';
-    document.body.style.touchAction = '';
     const t = getTimeFromX(clientX);
     if (t !== null) seek(t);
     setTouchActive(false);
@@ -78,20 +77,33 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
     // Only handle primary pointer (left click / first touch)
     if (e.button !== 0) return;
     e.preventDefault();
+
+    // Reset any stale state from previous incomplete drag
+    draggingRef.current = false;
+    setTouchActive(false);
+
+    // Begin new drag
     draggingRef.current = true;
     document.body.style.userSelect = 'none';
     document.body.style.cursor = 'col-resize';
-    document.body.style.touchAction = 'none'; // prevent scroll while scrubbing
+
+    // Capture pointer so mouseup always fires even if pointer leaves the element
+    (e.target as HTMLElement).setPointerCapture(e.pointerId);
+
     const t = getTimeFromX(e.clientX);
     if (t !== null) seek(t);
   };
 
-  // Touch-specific: show tooltip while dragging
+  // Touch events — also set draggingRef so window listeners work for touch
   const handleTouchStart = (e: React.TouchEvent) => {
+    if (e.touches.length !== 1) return;
     const touch = e.touches[0];
     if (!touch) return;
+    draggingRef.current = true;
     setTouchActive(true);
     setTouchX(touch.clientX);
+    const t = getTimeFromX(touch.clientX);
+    if (t !== null) seek(t);
   };
 
   const handleTouchMove = (e: React.TouchEvent) => {

+ 74 - 76
src/components/video-player/VideoPlayer.tsx

@@ -59,7 +59,6 @@ export function VideoPlayer({
   const [muted, setMuted] = useState(false);
   const [playbackRate, setPlaybackRate] = useState(1);
   const [fullscreen, setFullscreen] = useState(false);
-  const [showControls, setShowControls] = useState(true);
   const [dims, setDims] = useState({ width: 0, height: 0 });
   // Speech bubble — auto-show within ±1s of comment timestamp
   const BUBBLE_WINDOW = 1;
@@ -92,7 +91,6 @@ export function VideoPlayer({
     setBubbleVisible(hasMatch);
   }, [currentTime, comments]);
 
-  const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
   const redrawAnnotationsRef = useRef<(time: number) => void>(() => {});
 
   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);
+
+    // 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 () => {
       if (videoCallbackRef.current !== null) {
         try { (video as any).cancelVideoFrameCallback(videoCallbackRef.current); } catch { /* */ }
         videoCallbackRef.current = null;
       }
+      video.removeEventListener('seeked', onSeeked);
     };
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
@@ -197,12 +206,6 @@ export function VideoPlayer({
     return () => window.removeEventListener('keydown', handleKey);
   }, [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) {
     const video = videoRef.current;
     if (!video) return;
@@ -231,14 +234,15 @@ export function VideoPlayer({
   function toggleMute() {
     const video = videoRef.current;
     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;
       setVolume(0);
-      setMuted(true);
+    } else {
+      video.volume = volume > 0 ? volume : 1;
+      setVolume(video.volume);
     }
   }
 
@@ -275,8 +279,6 @@ export function VideoPlayer({
       <div
         ref={containerRef}
         className="relative bg-black rounded-xl overflow-hidden select-none group"
-        onMouseMove={resetHideTimer}
-        onMouseLeave={() => playing && setShowControls(false)}
       >
         <video
           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 */}
         {activeComment && !drawMode && (
@@ -382,9 +337,19 @@ export function VideoPlayer({
         )}
       </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 && (
-        <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>
           {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
             <button
@@ -419,16 +384,6 @@ export function VideoPlayer({
         </div>
       )}
 
-      {/* Timeline */}
-      <Timeline
-        duration={duration}
-        currentTime={currentTime}
-        fps={fps}
-        comments={comments}
-        onSeek={handleSeek}
-        onCommentClick={onCommentClick}
-      />
-
       {/* ── Bottom controls row ───────────────────────────────────────── */}
       <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)' }}
           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">
             {volBars === 0 ? (
               <>
@@ -478,6 +432,50 @@ export function VideoPlayer({
 
         <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 */}
         <select
           value={playbackRate}