import { useRef, useEffect, memo } from "react"; import gsap from "gsap"; import { MorphSVGPlugin } from "gsap/MorphSVGPlugin"; import { formatFrameTime, formatTime } from "../lib/time"; import { liveTime, usePlayerStore } from "../store/playerStore"; import { trackStudioEvent } from "../../utils/studioTelemetry"; import { Tooltip } from "../../components/ui"; import { useMountEffect } from "../../hooks/useMountEffect"; import { useSoloBannerText } from "../../hooks/useAudioSoloBridge"; import { ShortcutsPanel } from "./ShortcutsPanel"; import { SpeedMenu } from "./SpeedMenu"; import { VolumeControl } from "./VolumeControl"; /* ── 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 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 ( ); }); const SoloBanner = memo(function SoloBanner({ previewIframeRef, }: { previewIframeRef: { current: HTMLIFrameElement | null }; }) { const bannerText = useSoloBannerText(previewIframeRef); const clearSolo = usePlayerStore.getState().clearSolo; if (bannerText === null) return null; return (
Hearing only {bannerText} — your export is not affected
); }); /* ── Main component ──────────────────────────────────────────────── */ interface PlayerControlsProps { onTogglePlay: () => void; onSeek: (time: number) => void; disabled?: boolean; isFullscreen?: boolean; onToggleFullscreen?: () => void; previewIframeRef?: { current: HTMLIFrameElement | null }; } export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSeek, disabled = false, isFullscreen = false, onToggleFullscreen, previewIframeRef, }: 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 audioVolume = usePlayerStore((s) => s.audioVolume); const loopEnabled = usePlayerStore((s) => s.loopEnabled); const setPlaybackRate = usePlayerStore.getState().setPlaybackRate; const setAudioMuted = usePlayerStore.getState().setAudioMuted; const setAudioVolume = usePlayerStore.getState().setAudioVolume; 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 timeDisplayRef = useRef(null); const currentTimeRef = useRef(0); const timeDisplayModeRef = useRef(timeDisplayMode); timeDisplayModeRef.current = timeDisplayMode; const durationRef = useRef(duration); durationRef.current = duration; const controlsDisabled = disabled || !timelineReady; useEffect(() => { if (!timeDisplayRef.current) return; const t = currentTimeRef.current; timeDisplayRef.current.textContent = timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t); }, [duration, timeDisplayMode]); useMountEffect(() => { const updateTime = (time: number) => { currentTimeRef.current = time; if (!timeDisplayRef.current) return; const currentDuration = durationRef.current; timeDisplayRef.current.textContent = timeDisplayModeRef.current === "frame" ? formatFrameTime(time, currentDuration) : formatTime(time); }; const unsubscribe = liveTime.subscribe(updateTime); updateTime(usePlayerStore.getState().currentTime); return unsubscribe; }); return (
{previewIframeRef && }
{onToggleFullscreen && ( )}
); });