import { useRef, useState, useCallback, useEffect, memo } from "react"; import { useMountEffect } from "../../hooks/useMountEffect"; import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time"; import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers"; import { usePlayerStore, liveTime } from "../store/playerStore"; const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const; const SEEK_EDGE_SNAP_PX = 8; type TimeDisplayMode = "time" | "frame"; const SHORTCUT_SECTIONS = [ { title: "Playback", hints: [ { key: "Space", label: "Play / Pause" }, { key: "J", label: "Play backward" }, { key: "K", label: "Stop" }, { key: "L", label: "Play forward" }, { key: "←/→", label: "Step 1 frame" }, { key: "⇧←/⇧→", label: "Step 10 frames" }, ], }, { title: "Work area", hints: [ { key: "I", label: "Set in-point" }, { key: "⇧I", label: "Clear in-point" }, { key: "O", label: "Set out-point" }, { key: "⇧O", label: "Clear out-point" }, { key: "A", label: "Jump to in-point" }, { key: "E", label: "Jump to out-point" }, ], }, ] as const; export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number { if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0; const rawPercent = (clientX - rectLeft) / rectWidth; const clamped = Math.max(0, Math.min(1, rawPercent)); const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth); if (clamped <= snapThreshold) return 0; if (clamped >= 1 - snapThreshold) return 1; return clamped; } interface PlayerControlsProps { onTogglePlay: () => void; onSeek: (time: number) => void; disabled?: boolean; } export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSeek, disabled = false, }: PlayerControlsProps) { // Subscribe to only the fields we render — each selector prevents cascading re-renders const isPlaying = usePlayerStore((s) => s.isPlaying); const duration = usePlayerStore((s) => s.duration); const timelineReady = usePlayerStore((s) => s.timelineReady); const playbackRate = usePlayerStore((s) => s.playbackRate); const audioMuted = usePlayerStore((s) => s.audioMuted); const loopEnabled = usePlayerStore((s) => s.loopEnabled); const setPlaybackRate = usePlayerStore.getState().setPlaybackRate; const setAudioMuted = usePlayerStore.getState().setAudioMuted; const setLoopEnabled = usePlayerStore.getState().setLoopEnabled; const inPoint = usePlayerStore((s) => s.inPoint); const outPoint = usePlayerStore((s) => s.outPoint); const setInPoint = usePlayerStore.getState().setInPoint; const setOutPoint = usePlayerStore.getState().setOutPoint; const [showSpeedMenu, setShowSpeedMenu] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); const [timeDisplayMode, setTimeDisplayMode] = useState("time"); const [jumpFrame, setJumpFrame] = useState(""); const progressFillRef = useRef(null); const progressThumbRef = useRef(null); const timeDisplayRef = useRef(null); const seekBarRef = useRef(null); const sliderRef = useRef(null); const speedMenuContainerRef = useRef(null); const shortcutsPanelRef = useRef(null); const isDraggingRef = useRef(false); const currentTimeRef = useRef(0); const timeDisplayModeRef = useRef(timeDisplayMode); timeDisplayModeRef.current = timeDisplayMode; const durationRef = useRef(duration); durationRef.current = duration; const controlsDisabled = disabled || !timelineReady; const audioAutoMuted = playbackRate > 1; const effectiveAudioMuted = shouldMutePreviewAudio(audioMuted, playbackRate); const muteButtonLabel = audioAutoMuted ? "Audio muted above 1x speed" : audioMuted ? "Unmute audio" : "Mute audio"; useMountEffect(() => { const updateProgress = (t: number) => { currentTimeRef.current = t; const dur = durationRef.current; const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0; if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`; if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`; if (timeDisplayRef.current) { timeDisplayRef.current.textContent = timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t); } if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t))); }; const unsub = liveTime.subscribe(updateProgress); updateProgress(usePlayerStore.getState().currentTime); // Also poll every 500ms as a fallback in case liveTime doesn't fire const interval = setInterval(() => { const t = usePlayerStore.getState().currentTime; const dur = usePlayerStore.getState().duration; if (dur > 0 && t > 0) { const pct = Math.min(100, (t / dur) * 100); if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`; if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`; } }, 500); return () => { unsub(); clearInterval(interval); }; }); useEffect(() => { if (!timeDisplayRef.current) return; const t = currentTimeRef.current; timeDisplayRef.current.textContent = timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t); }, [duration, timeDisplayMode]); useEffect(() => { if (!showSpeedMenu) return; const handleMouseDown = (e: MouseEvent) => { if ( speedMenuContainerRef.current && !speedMenuContainerRef.current.contains(e.target as Node) ) { setShowSpeedMenu(false); } }; document.addEventListener("mousedown", handleMouseDown); return () => { document.removeEventListener("mousedown", handleMouseDown); }; }, [showSpeedMenu]); useEffect(() => { if (!showShortcuts) return; const handleMouseDown = (e: MouseEvent) => { if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) { setShowShortcuts(false); } }; document.addEventListener("mousedown", handleMouseDown); return () => { document.removeEventListener("mousedown", handleMouseDown); }; }, [showShortcuts]); const seekFromClientX = useCallback( (clientX: number) => { if (disabled) return; const bar = seekBarRef.current; if (!bar || duration <= 0) return; const rect = bar.getBoundingClientRect(); const percent = resolveSeekPercent(clientX, rect.left, rect.width); // Immediately update progress bar visuals (don't wait for liveTime round-trip) const pct = percent * 100; if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`; if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`; onSeek(percent * duration); }, [disabled, duration, onSeek], ); const handlePointerDown = useCallback( (e: React.PointerEvent) => { // Ignore secondary mouse buttons — only primary (left click / touch / // pen contact) should start a drag. if (e.button !== 0) return; e.preventDefault(); // preventDefault() on pointerdown also suppresses the implicit focus // transfer that click normally grants a `tabIndex=0` element — which // matches native `` behavior, but it also means a // click-then-arrow-key workflow wouldn't work. Restore focus explicitly // so seeking by click and nudging by arrow keys compose naturally. e.currentTarget.focus(); isDraggingRef.current = true; // `setPointerCapture` routes every subsequent pointermove/up to the // slider element even when the pointer leaves its bounding box. Without // it, fast drags on touch would lose events the moment the finger // slips outside the 6 px-tall hit zone. const target = e.currentTarget; const pointerId = e.pointerId; try { target.setPointerCapture(pointerId); } catch { /* non-supporting browsers fall back to window listeners below */ } seekFromClientX(e.clientX); const onMove = (ev: PointerEvent) => { if (ev.pointerId !== pointerId) return; if (isDraggingRef.current) seekFromClientX(ev.clientX); }; const cleanup = () => { isDraggingRef.current = false; try { target.releasePointerCapture(pointerId); } catch { /* Already released after the first cleanup — second invocation via the window-fallback or visibility path is a no-op throw. */ } target.removeEventListener("pointermove", onMove); target.removeEventListener("pointerup", onUp); target.removeEventListener("pointercancel", onUp); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); document.removeEventListener("visibilitychange", onVisibilityChange); window.removeEventListener("blur", cleanup); }; const onUp = (ev: PointerEvent) => { if (ev.pointerId !== pointerId) return; cleanup(); }; // iOS Safari does not reliably fire `pointercancel` when the page is // backgrounded mid-drag (alt-tab, incoming call, switch apps). Without // a release path the ref stays `true` until the next pointerdown — a // stuck-scrubber class bug waiting to happen if anyone later gates // rendering on `isDragging`. Synthesize the release on hide / blur. const onVisibilityChange = () => { if (document.visibilityState === "hidden") cleanup(); }; target.addEventListener("pointermove", onMove); target.addEventListener("pointerup", onUp); target.addEventListener("pointercancel", onUp); // Window-level fallback in case capture fails and the pointer release // lands outside the element (rare, but defensive). window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); document.addEventListener("visibilitychange", onVisibilityChange); window.addEventListener("blur", cleanup); }, [seekFromClientX], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (disabled || !timelineReady || duration <= 0) return; const step = e.shiftKey ? 10 : 1; if (e.key === "ArrowLeft") { e.preventDefault(); onSeek(stepFrameTime(currentTimeRef.current, -step)); } else if (e.key === "ArrowRight") { e.preventDefault(); onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step))); } }, [disabled, timelineReady, duration, onSeek], ); const commitJumpFrame = useCallback(() => { if (disabled) return; const frame = Number.parseInt(jumpFrame, 10); if (!Number.isFinite(frame) || duration <= 0) return; onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame)))); }, [disabled, duration, jumpFrame, onSeek]); const handleJumpSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); commitJumpFrame(); }, [commitJumpFrame], ); const handleJumpKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key !== "Enter") return; e.preventDefault(); commitJumpFrame(); }, [commitJumpFrame], ); return (
{/* Play/Pause button */} {/* Time display — click to toggle time/frame mode */} {/* Seek bar — teal progress fill */}
{ (seekBarRef as React.MutableRefObject).current = el; (sliderRef as React.MutableRefObject).current = el; }} role="slider" tabIndex={disabled ? -1 : 0} aria-label="Seek" aria-disabled={disabled || undefined} aria-valuemin={0} aria-valuemax={Math.round(duration)} aria-valuenow={0} className={`min-w-[96px] flex-1 h-6 flex items-center group ${ disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer" }`} // `touch-action: none` tells the browser we're handling every // pointer gesture on this element ourselves. Without it, iOS // Safari consumes horizontal swipes for its own swipe-back-to- // previous-page navigation and the scrubber can't drag left. style={{ touchAction: "none" }} onPointerDown={handlePointerDown} onKeyDown={handleKeyDown} >
{/* Work-area band between in/out points */} {(inPoint !== null || outPoint !== null) && duration > 0 && (
)} {/* Progress fill — width is controlled imperatively via ref to avoid React re-render resets */}
{/* In-point marker */} {inPoint !== null && duration > 0 && (
)} {/* Out-point marker */} {outPoint !== null && duration > 0 && (
)} {/* Playhead thumb — left is controlled imperatively via ref */}
{/* Mute toggle */} {/* Speed control */}
{showSpeedMenu && (
{SPEED_OPTIONS.map((rate) => ( ))}
)}
{/* Keyboard shortcuts + frame jump + work area — click to open panel */}
{showShortcuts && (
{/* Frame jump */}

Jump to frame

setJumpFrame(e.target.value)} disabled={disabled} inputMode="numeric" pattern="[0-9]*" aria-label="Jump to frame" placeholder="frame number" className="h-6 flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60" onKeyDown={handleJumpKeyDown} onBlur={commitJumpFrame} />
{/* Work area */}

Work area

I In-point
{inPoint !== null ? ( <> {formatTime(inPoint)} ) : ( )}
O Out-point
{outPoint !== null ? ( <> {formatTime(outPoint)} ) : ( )}
{/* Shortcuts */}
{SHORTCUT_SECTIONS.map((section) => (

{section.title}

{section.hints.map((hint) => (
{hint.key} {hint.label}
))}
))}
)}
); });