import { useRef, useCallback, useEffect, memo } from "react"; import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time"; import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers"; import { usePlayerStore } from "../store/playerStore"; import { trackStudioEvent } from "../../utils/studioTelemetry"; import { Tooltip } from "../../components/ui"; import { ShortcutsPanel } from "./ShortcutsPanel"; import { SpeedMenu } from "./SpeedMenu"; import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag"; import { useState } from "react"; export { resolveSeekPercent }; type TimeDisplayMode = "time" | "frame"; /* ── Icon sub-components ─────────────────────────────────────────── */ function PlayIcon() { return ( ); } function PauseIcon() { return ( ); } /* ── Button sub-components ───────────────────────────────────────── */ const MuteButton = memo(function MuteButton({ audioMuted, audioAutoMuted, effectiveAudioMuted, controlsDisabled, setAudioMuted, }: { audioMuted: boolean; audioAutoMuted: boolean; effectiveAudioMuted: boolean; controlsDisabled: boolean; setAudioMuted: (v: boolean) => void; }) { const label = audioAutoMuted ? "Audio muted above 1x speed" : audioMuted ? "Unmute audio" : "Mute audio"; return ( ); }); const LoopButton = memo(function LoopButton({ loopEnabled, disabled, setLoopEnabled, }: { loopEnabled: boolean; disabled: boolean; setLoopEnabled: (v: boolean) => void; }) { return ( ); }); const FullscreenButton = memo(function FullscreenButton({ isFullscreen, onToggleFullscreen, }: { isFullscreen: boolean; onToggleFullscreen: () => void; }) { return ( ); }); /* ── Seek bar sub-component ──────────────────────────────────────── */ function SeekBarMarker({ position, duration }: { position: number; duration: number }) { if (duration <= 0) return null; return (
); } function WorkAreaOverlay({ inPoint, outPoint, duration, }: { inPoint: number | null; outPoint: number | null; duration: number; }) { if ((inPoint === null && outPoint === null) || duration <= 0) return null; return ( <>
{inPoint !== null && } {outPoint !== null && } ); } const SeekBar = memo(function SeekBar({ disabled, duration, inPoint, outPoint, progressFillRef, progressThumbRef, seekBarRef, sliderRef, onPointerDown, onKeyDown, }: { disabled: boolean; duration: number; inPoint: number | null; outPoint: number | null; progressFillRef: React.RefObject; progressThumbRef: React.RefObject; seekBarRef: React.RefObject; sliderRef: React.RefObject; onPointerDown: (e: React.PointerEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; }) { return (
{ (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 outline-none focus-visible:ring-1 focus-visible:ring-white/30 focus-visible:rounded ${ disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer" }`} style={{ touchAction: "none" }} onPointerDown={onPointerDown} onKeyDown={onKeyDown} >
); }); /* ── Main component ──────────────────────────────────────────────── */ interface PlayerControlsProps { onTogglePlay: () => void; onSeek: (time: number) => void; disabled?: boolean; isFullscreen?: boolean; onToggleFullscreen?: () => void; } export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSeek, disabled = false, isFullscreen = false, onToggleFullscreen, }: PlayerControlsProps) { 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 [timeDisplayMode, setTimeDisplayMode] = useState("time"); const progressFillRef = useRef(null); const progressThumbRef = useRef(null); const timeDisplayRef = useRef(null); const seekBarRef = useRef(null); const sliderRef = 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); useEffect(() => { if (!timeDisplayRef.current) return; const t = currentTimeRef.current; timeDisplayRef.current.textContent = timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t); }, [duration, timeDisplayMode]); const { handlePointerDown } = useSeekBarDrag( { seekBarRef, progressFillRef, progressThumbRef, sliderRef, timeDisplayRef, isDraggingRef, durationRef, currentTimeRef, timeDisplayModeRef, }, onSeek, disabled, duration, ); 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], ); return (
{onToggleFullscreen && ( )}
); });