import { useRef, useCallback, useEffect, memo } from "react"; import gsap from "gsap"; import { MorphSVGPlugin } from "gsap/MorphSVGPlugin"; 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"; export { resolveSeekPercent }; /* ── Icon sub-components ─────────────────────────────────────────── */ gsap.registerPlugin(MorphSVGPlugin); // Play glyph: the right-hand blade from the HyperFrames favicon (points right). // Pause glyph: two bars centred in the same coordinate space so MorphSVG can // tween one `d` into the other. Both shapes live in the favicon's 0-100 space // and the svg viewBox frames the blade's bounding box. const PLAY_BLADE_D = "M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z"; const PAUSE_BARS_D = "M56 28H67V71H56Z M73 28H84V71H73Z"; // Morph the play blade <-> pause bars on toggle via GSAP MorphSVG. Both glyphs // are one path whose `d` tweens; the initial render matches `playing` with no // animation, and prefers-reduced-motion snaps instead of tweening. function PlayPauseMorphIcon({ playing }: { playing: boolean }) { const pathRef = useRef(null); const isFirstRun = useRef(true); useEffect(() => { const el = pathRef.current; if (!el) return; const target = playing ? PAUSE_BARS_D : PLAY_BLADE_D; const reduceMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; if (isFirstRun.current || reduceMotion) { isFirstRun.current = false; gsap.set(el, { morphSVG: target }); return; } const tween = gsap.to(el, { duration: 0.28, ease: "power2.inOut", morphSVG: target }); return () => { tween.kill(); }; }, [playing]); 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 = usePlayerStore((s) => s.timeDisplayMode); const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode; 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 && ( )}
); });