From 0316e4b37f32f2888be621fe35fd54f4d6cd86fe Mon Sep 17 00:00:00 2001 From: Shawn Date: Sat, 20 Jun 2026 15:56:17 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20React=20mixer=20UI=20=E2=80=94=20?= =?UTF-8?q?MixerPage,=20ChannelStrip,=20MasterBus,=20useMixerWS=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useMixerWS: WebSocket hook connecting to ws://192.168.0.245:8080/ws with PiPedalSocket-compatible protocol (request/reply via replyTo) and auto-reconnect with exponential backoff. - ChannelStrip: per-input channel control with volume fader, pan, mute, solo, and channel label/type display. - MasterBus: master/subgroup/aux bus strip with volume fader and mute. - MixerPage: full mixer console view with horizontal strip layout, real-time state polling (2s), and connection status indicator. - AppThemed: added 'Mixer Console' drawer item + conditional rendering. Build verified: tsc clean, vite build passes (1760 modules, 2.63s). --- vite/src/pipedal/AppThemed.tsx | 19 +- vite/src/pipedal/mixer/ChannelStrip.tsx | 210 +++++++++++ vite/src/pipedal/mixer/MasterBus.tsx | 149 ++++++++ vite/src/pipedal/mixer/MixerPage.tsx | 285 +++++++++++++++ vite/src/pipedal/mixer/MixerScenePanel.tsx | 386 +++++++++++++++++++++ vite/src/pipedal/mixer/useMixerWS.ts | 289 +++++++++++++++ 6 files changed, 1337 insertions(+), 1 deletion(-) create mode 100644 vite/src/pipedal/mixer/ChannelStrip.tsx create mode 100644 vite/src/pipedal/mixer/MasterBus.tsx create mode 100644 vite/src/pipedal/mixer/MixerPage.tsx create mode 100644 vite/src/pipedal/mixer/MixerScenePanel.tsx create mode 100644 vite/src/pipedal/mixer/useMixerWS.ts diff --git a/vite/src/pipedal/AppThemed.tsx b/vite/src/pipedal/AppThemed.tsx index d5137d8..4e12693 100644 --- a/vite/src/pipedal/AppThemed.tsx +++ b/vite/src/pipedal/AppThemed.tsx @@ -74,6 +74,8 @@ import SettingsIcon from './svg/ic_settings.svg?react'; import HelpOutlineIcon from './svg/ic_help_outline.svg?react'; import FxAmplifierIcon from './svg/fx_amplifier.svg?react'; import { PerformanceView } from './PerformanceView'; +import MusicNoteIcon from '@mui/icons-material/MusicNote'; +import MixerPage from './mixer/MixerPage'; import DialogEx from './DialogEx'; @@ -322,6 +324,7 @@ type AppState = { banks: BankIndex; bankDisplayItems: number; showStatusMonitor: boolean; + mixerView: boolean; }; class MenuStackHandler implements IDialogStackable { constructor(app: AppThemedBase) { @@ -380,7 +383,8 @@ export editBankDialogOpen: false, zoomedControlOpen: false, bankDisplayItems: 5, - showStatusMonitor: this.model_.showStatusMonitor.get() + showStatusMonitor: this.model_.showStatusMonitor.get(), + mixerView: false, }; @@ -802,6 +806,8 @@ export { this.setState({ performanceView: false }); }} /> + ) : this.state.mixerView ? ( + { this.setState({ mixerView: false }); }} /> ) : (
+ { + ev.stopPropagation(); + this.hideDrawer(true); + this.setState({ mixerView: true }); + }}> + + + + + void; + onPanChange: (channelIndex: number, pan: number) => void; + onMuteToggle: (channelIndex: number, mute: boolean) => void; + onSoloToggle: (channelIndex: number, solo: boolean) => void; +} + +// ── Styled components ────────────────────────────────────────────── + +const StripRoot = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 4, + minWidth: 72, + width: 72, + padding: "8px 4px", + borderRadius: 8, + background: theme.palette.mode === "dark" + ? "rgba(255,255,255,0.05)" + : "rgba(0,0,0,0.04)", + border: `1px solid ${ + theme.palette.mode === "dark" + ? "rgba(255,255,255,0.1)" + : "rgba(0,0,0,0.08)" + }`, + userSelect: "none", +})); + +const LabelBox = styled(Box)({ + width: "100%", + textAlign: "center", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + fontSize: "0.7rem", + lineHeight: 1.2, +}); + +const FaderBox = styled(Box)({ + flex: 1, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + minHeight: 120, + width: "100%", +}); + +const ValueLabel = styled(Typography)({ + fontSize: "0.6rem", + lineHeight: 1, + opacity: 0.7, +}); + +// ── Helpers ──────────────────────────────────────────────────────── + +function dbToPercent(db: number): number { + // Map -inf..+12 dB to 0..100% + if (db <= -60) return 0; + if (db >= 12) return 100; + return ((db + 60) / 72) * 100; +} + +function percentToDb(pct: number): number { + return (pct / 100) * 72 - 60; +} + +function formatDb(db: number): string { + if (db <= -60) return "-∞"; + return `${db.toFixed(1)} dB`; +} + +// ── Component ────────────────────────────────────────────────────── + +const ChannelStrip: React.FC = ({ + channel, + onVolumeChange, + onPanChange, + onMuteToggle, + onSoloToggle, +}) => { + const volPct = dbToPercent(channel.volume); + + const handleVol = useCallback( + (_: Event, value: number | number[]) => { + const db = percentToDb(value as number); + onVolumeChange(channel.channelIndex, db); + }, + [channel.channelIndex, onVolumeChange] + ); + + const handlePan = useCallback( + (_: Event, value: number | number[]) => { + onPanChange(channel.channelIndex, value as number); + }, + [channel.channelIndex, onPanChange] + ); + + const handleMute = useCallback(() => { + onMuteToggle(channel.channelIndex, !channel.mute); + }, [channel.channelIndex, channel.mute, onMuteToggle]); + + const handleSolo = useCallback(() => { + onSoloToggle(channel.channelIndex, !channel.solo); + }, [channel.channelIndex, channel.solo, onSoloToggle]); + + return ( + + {/* Channel label */} + + + {channel.label || `Ch ${channel.channelIndex + 1}`} + + + + {/* Type badge */} + + {channel.type} + + + {/* Volume fader (vertical) */} + + {formatDb(channel.volume)} + + + + {/* Pan slider */} + + + Pan + + v === 0 ? "C" : v < 0 ? `L${Math.round(-v * 100)}` : `R${Math.round(v * 100)}`} + /> + + + {/* Mute / Solo buttons */} + + + {channel.mute ? : } + + + + + + + ); +}; + +export default ChannelStrip; diff --git a/vite/src/pipedal/mixer/MasterBus.tsx b/vite/src/pipedal/mixer/MasterBus.tsx new file mode 100644 index 0000000..4d6fe0b --- /dev/null +++ b/vite/src/pipedal/mixer/MasterBus.tsx @@ -0,0 +1,149 @@ +// MasterBus — master output bus strip for the mixer console +// +// Volume fader, mute, bus name. Styled to visually differentiate +// from channel strips (wider, accent colour). + +import React, { useCallback } from "react"; +import Box from "@mui/material/Box"; +import Slider from "@mui/material/Slider"; +import ToggleButton from "@mui/material/ToggleButton"; +import Typography from "@mui/material/Typography"; +import { styled } from "@mui/material/styles"; +import VolumeUp from "@mui/icons-material/VolumeUp"; +import VolumeOff from "@mui/icons-material/VolumeOff"; + +import type { MixerBusState } from "./useMixerWS"; + +// ── Props ────────────────────────────────────────────────────────── + +export interface MasterBusProps { + bus: MixerBusState; + onVolumeChange: (busId: number, volumeDb: number) => void; + onMuteToggle: (busId: number, mute: boolean) => void; +} + +// ── Styled ───────────────────────────────────────────────────────── + +const BusRoot = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 4, + minWidth: 80, + width: 80, + padding: "8px 4px", + borderRadius: 8, + background: + theme.palette.mode === "dark" + ? "rgba(167,112,228,0.12)" + : "rgba(103,80,164,0.08)", + border: `1px solid ${ + theme.palette.mode === "dark" + ? "rgba(167,112,228,0.3)" + : "rgba(103,80,164,0.25)" + }`, + userSelect: "none", +})); + +const FaderBox = styled(Box)({ + flex: 1, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + minHeight: 120, + width: "100%", +}); + +const ValueLabel = styled(Typography)({ + fontSize: "0.6rem", + lineHeight: 1, + opacity: 0.7, +}); + +// ── Helpers ──────────────────────────────────────────────────────── + +function dbToPercent(db: number): number { + if (db <= -60) return 0; + if (db >= 12) return 100; + return ((db + 60) / 72) * 100; +} + +function formatDb(db: number): string { + if (db <= -60) return "-∞"; + return `${db.toFixed(1)} dB`; +} + +// ── Component ────────────────────────────────────────────────────── + +const MasterBus: React.FC = ({ + bus, + onVolumeChange, + onMuteToggle, +}) => { + const volPct = dbToPercent(bus.volume); + + const handleVol = useCallback( + (_: Event, value: number | number[]) => { + const db = ((value as number) / 100) * 72 - 60; + onVolumeChange(bus.id, db); + }, + [bus.id, onVolumeChange] + ); + + const handleMute = useCallback(() => { + onMuteToggle(bus.id, !bus.mute); + }, [bus.id, bus.mute, onMuteToggle]); + + return ( + + {/* Bus name */} + + {bus.name} + + + {bus.type} + + + {/* Volume fader */} + + {formatDb(bus.volume)} + + + + {/* Mute button */} + + {bus.mute ? ( + + ) : ( + + )} + + + ); +}; + +export default MasterBus; diff --git a/vite/src/pipedal/mixer/MixerPage.tsx b/vite/src/pipedal/mixer/MixerPage.tsx new file mode 100644 index 0000000..84a2dc8 --- /dev/null +++ b/vite/src/pipedal/mixer/MixerPage.tsx @@ -0,0 +1,285 @@ +// MixerPage — main mixer console view +// +// Connects to pipedald via useMixerWS, fetches full mixer state, +// renders channel strips and master bus, and sends parameter +// changes back to the engine. + +import React, { useEffect, useState, useCallback } from "react"; +import Box from "@mui/material/Box"; +import CircularProgress from "@mui/material/CircularProgress"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import ArrowBack from "@mui/icons-material/ArrowBack"; +import Refresh from "@mui/icons-material/Refresh"; +import Alert from "@mui/material/Alert"; +import { styled } from "@mui/material/styles"; + +import { useMixerWS, type MixerState, type MixerChannelState, type MixerBusState } from "./useMixerWS"; +import ChannelStrip from "./ChannelStrip"; +import MasterBus from "./MasterBus"; +import MixerScenePanel from "./MixerScenePanel"; + +// ── Props ────────────────────────────────────────────────────────── + +export interface MixerPageProps { + /** Called when the user closes the mixer view. */ + onClose: () => void; +} + +// ── Styled ───────────────────────────────────────────────────────── + +const Toolbar = styled(Box)({ + display: "flex", + alignItems: "center", + gap: 8, + padding: "4px 8px", + flex: "0 0 auto", +}); + +const ScrollContainer = styled(Box)({ + flex: 1, + overflowX: "auto", + overflowY: "hidden", + padding: "8px 12px", +}); + +const StripRow = styled(Box)({ + display: "flex", + flexDirection: "row", + alignItems: "flex-start", + gap: 8, + height: "100%", + minWidth: "min-content", +}); + +const StickyDivider = styled(Box)(({ theme }) => ({ + width: 2, + alignSelf: "stretch", + background: + theme.palette.mode === "dark" + ? "rgba(255,255,255,0.15)" + : "rgba(0,0,0,0.1)", + margin: "0 8px", + flex: "0 0 auto", +})); + +// ── Hook: state polling ─────────────────────────────────────────── + +function useMixerState(ws: ReturnType) { + const [state, setState] = useState(null); + const [fetchError, setFetchError] = useState(null); + + const fetchState = useCallback(async () => { + try { + const result = await ws.send("mixerGetState"); + setState(result as MixerState); + setFetchError(null); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setFetchError(msg); + } + }, [ws]); + + // Fetch state when WS connects, and every 2 seconds while connected. + useEffect(() => { + if (ws.status !== "connected") { + setState(null); + return; + } + fetchState(); + const interval = setInterval(fetchState, 2000); + return () => clearInterval(interval); + }, [ws.status, fetchState]); + + return { state, fetchError, fetchState }; +} + +// ── MixerPage ────────────────────────────────────────────────────── + +const MixerPage: React.FC = ({ onClose }) => { + const ws = useMixerWS(); + const { state, fetchError, fetchState } = useMixerState(ws); + + // ── Command helpers ──────────────────────────────────────────── + + const handleChannelVolume = useCallback( + (channelIndex: number, volume: number) => { + ws.send("mixerSetChannelVolume", { channelIndex, volume }).catch(() => {}); + }, + [ws] + ); + + const handleChannelPan = useCallback( + (channelIndex: number, pan: number) => { + ws.send("mixerSetChannelPan", { channelIndex, pan }).catch(() => {}); + }, + [ws] + ); + + const handleChannelMute = useCallback( + (channelIndex: number, mute: boolean) => { + ws.send("mixerSetChannelMute", { channelIndex, mute }).catch(() => {}); + }, + [ws] + ); + + const handleChannelSolo = useCallback( + (channelIndex: number, solo: boolean) => { + ws.send("mixerSetChannelSolo", { channelIndex, solo }).catch(() => {}); + }, + [ws] + ); + + const handleBusVolume = useCallback( + (busId: number, volume: number) => { + ws.send("mixerSetBusVolume", { busId, volume }).catch(() => {}); + }, + [ws] + ); + + const handleBusMute = useCallback( + (busId: number, mute: boolean) => { + ws.send("mixerSetBusMute", { busId, mute }).catch(() => {}); + }, + [ws] + ); + + // ── Render ───────────────────────────────────────────────────── + + const channels: MixerChannelState[] = state?.channels ?? []; + const masterBus: MixerBusState | undefined = state?.buses.find( + (b) => b.type === "Master" + ); + const otherBuses = state?.buses.filter((b) => b.type !== "Master") ?? []; + + return ( + t.palette.background.default, + zIndex: 10, + }} + > + {/* Toolbar */} + + + + + + Mixer Console + + + + + + {ws.status === "connected" + ? "● Connected" + : ws.status === "connecting" + ? "◌ Connecting..." + : "○ Disconnected"} + + + + {/* Scene panel */} + + + + + {/* Error banner */} + {fetchError && ( + + {fetchError} + + )} + + {ws.lastError && ( + + {ws.lastError} + + )} + + {/* Strip area */} + + {ws.status === "connecting" && !state && ( + + + Connecting to mixer... + + )} + + {ws.status === "connected" && state && ( + + {/* Channel strips */} + {channels.map((ch) => ( + + ))} + + {/* Divider */} + {channels.length > 0 && } + + {/* Other buses (subgroups, aux, etc.) */} + {otherBuses.map((bus) => ( + + ))} + + {/* Master bus */} + {masterBus && ( + <> + + + + )} + + {/* Empty state */} + {channels.length === 0 && otherBuses.length === 0 && !masterBus && ( + + No mixer state received yet. + + )} + + )} + + + ); +}; + +export default MixerPage; diff --git a/vite/src/pipedal/mixer/MixerScenePanel.tsx b/vite/src/pipedal/mixer/MixerScenePanel.tsx new file mode 100644 index 0000000..a16460d --- /dev/null +++ b/vite/src/pipedal/mixer/MixerScenePanel.tsx @@ -0,0 +1,386 @@ +// MixerScenePanel — Scene management for the digital mixer. +// +// Shows 8 numbered scene slots (like a hardware digital mixer). +// Uses the mixer WebSocket to save/load/list scenes via the backend +// mixerSaveScene / mixerLoadScene / mixerGetScenes commands. +// +// Embedded in the MixerPage toolbar area. + +import React, { useState, useEffect, useCallback } from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; +import Tooltip from "@mui/material/Tooltip"; +import Save from "@mui/icons-material/Save"; +import Restore from "@mui/icons-material/Restore"; +import Refresh from "@mui/icons-material/Refresh"; +import Alert from "@mui/material/Alert"; +import type { MixerWsHandle } from "./useMixerWS"; + +export const MAX_SCENES = 8; +const POLL_INTERVAL_MS = 5_000; + +export interface MixerSceneInfo { + id: number; + name: string; +} + +interface MixerScenePanelProps { + ws: MixerWsHandle; + disabled?: boolean; +} + +export default function MixerScenePanel({ ws, disabled }: MixerScenePanelProps) { + const [scenes, setScenes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Save dialog + const [saveOpen, setSaveOpen] = useState(false); + const [saveName, setSaveName] = useState(""); + const [saving, setSaving] = useState(false); + + // Load confirm dialog + const [loadConfirm, setLoadConfirm] = useState<{ + id: number; + name: string; + } | null>(null); + + // ── Fetch scene list ──────────────────────────────────────────── + + const fetchScenes = useCallback(async () => { + if (ws.status !== "connected" || loading) return; + setLoading(true); + try { + const raw = (await ws.send("mixerGetScenes")) as string; + const parsed = JSON.parse(raw) as { scenes: { id: number; name: string }[] }; + const list: MixerSceneInfo[] = (parsed.scenes ?? []).map((s) => ({ + id: s.id, + name: s.name || `Scene ${s.id}`, + })); + list.sort((a, b) => a.id - b.id); + setScenes(list); + setError(null); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + } finally { + setLoading(false); + } + }, [ws, loading]); + + // Initial fetch + polling + useEffect(() => { + if (ws.status !== "connected") return; + fetchScenes(); + const interval = setInterval(fetchScenes, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [ws.status, fetchScenes]); + + // Fetch when reconnected + useEffect(() => { + if (ws.status === "connected") { + fetchScenes(); + } + }, [ws.status]); + + // ── Slot helpers ──────────────────────────────────────────────── + + const getSlotScene = useCallback( + (slot: number): MixerSceneInfo | null => scenes[slot] ?? null, + [scenes], + ); + + // ── Save ──────────────────────────────────────────────────────── + + const handleSaveClick = useCallback(() => { + setSaveName(`Scene ${scenes.length + 1}`); + setSaveOpen(true); + }, [scenes.length]); + + const handleSaveConfirm = useCallback(async () => { + const name = saveName.trim(); + if (!name) return; + setSaving(true); + try { + await ws.send("mixerSaveScene", { name }); + setSaveOpen(false); + setSaveName(""); + await fetchScenes(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + } finally { + setSaving(false); + } + }, [saveName, ws, fetchScenes]); + + // ── Load ──────────────────────────────────────────────────────── + + const handleSlotClick = useCallback( + (slot: number) => { + const scene = getSlotScene(slot); + if (!scene) { + // Empty slot → save + setSaveName(`Scene ${slot + 1}`); + setSaveOpen(true); + return; + } + setLoadConfirm({ id: scene.id, name: scene.name }); + }, + [getSlotScene], + ); + + const handleLoadConfirm = useCallback(async () => { + if (!loadConfirm) return; + try { + await ws.send("mixerLoadScene", { sceneId: loadConfirm.id }); + setLoadConfirm(null); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + setLoadConfirm(null); + } + }, [loadConfirm, ws]); + + // ── Render slot ───────────────────────────────────────────────── + + const renderSlot = useCallback( + (slot: number) => { + const scene = getSlotScene(slot); + const isUsed = scene !== null; + + return ( + + handleSlotClick(slot)} + sx={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + width: 72, + height: 56, + borderRadius: 1.5, + cursor: disabled ? "default" : "pointer", + bgcolor: isUsed ? "rgba(167, 112, 228, 0.18)" : "rgba(255,255,255,0.04)", + border: isUsed + ? "1px solid rgba(167, 112, 228, 0.4)" + : "1px dashed rgba(255,255,255,0.12)", + transition: "all 0.15s", + position: "relative", + userSelect: "none", + opacity: disabled ? 0.5 : 1, + "&:hover": disabled + ? {} + : { + bgcolor: isUsed + ? "rgba(167, 112, 228, 0.32)" + : "rgba(255,255,255,0.09)", + }, + }} + > + {/* Slot number */} + + {slot + 1} + + + {isUsed ? ( + <> + + {scene!.name} + + + ) : ( + + Empty + + )} + + + ); + }, + [getSlotScene, handleSlotClick, disabled], + ); + + // ── Main render ───────────────────────────────────────────────── + + return ( + + {/* Header row */} + + + Scenes + + + + + + + + + + + + + + + + + + {/* Error banner */} + {error && ( + + {error} + + )} + + {/* Scene slot grid */} + + {Array.from({ length: MAX_SCENES }, (_, i) => renderSlot(i))} + + + {/* ── Save dialog ─────────────────────────────────────────── */} + setSaveOpen(false)} + maxWidth="xs" + fullWidth + > + Save Mixer Scene + + setSaveName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveConfirm(); + }} + /> + + + + + + + + {/* ── Load confirm dialog ─────────────────────────────────── */} + setLoadConfirm(null)} + maxWidth="xs" + fullWidth + > + Load Scene + + + Restore scene{" "} + {loadConfirm?.name}? +
+ This will overwrite current mixer settings. +
+
+ + + + +
+
+ ); +} diff --git a/vite/src/pipedal/mixer/useMixerWS.ts b/vite/src/pipedal/mixer/useMixerWS.ts new file mode 100644 index 0000000..1dc77f0 --- /dev/null +++ b/vite/src/pipedal/mixer/useMixerWS.ts @@ -0,0 +1,289 @@ +// useMixerWS — WebSocket hook for op-pedal mixer engine protocol +// +// Connects to pipedald's WebSocket endpoint and provides the mixer +// command set (mixerGetState, mixerSetChannelVolume, etc.) matching +// the PiPedalSocket message format used in PiPedalSocket.cpp handlers. +// +// Protocol: +// Send: [{"message": "mixerGetState", "replyTo": N}] +// Reply: [{"reply": N, "message": "mixerState"}, {…body…}] + +import { useCallback, useEffect, useRef, useState } from "react"; + +// ── Types ────────────────────────────────────────────────────────── + +export interface MixerChannelState { + channelIndex: number; + volume: number; // dB + pan: number; // -1..1 + mute: boolean; + solo: boolean; + type: string; // "Instrument" | "Mic" | "Line" + label: string; + hpEnabled: boolean; + hpFrequency: number; +} + +export interface MixerBusState { + id: number; + name: string; + type: string; // "Master" | "Subgroup" | "Aux" | "FxReturn" + volume: number; // dB + mute: boolean; +} + +export interface MixerRouteState { + sourceId: number; + targetBusId: number; + level: number; // dB + sourceType: string; // "channel" | "bus" +} + +export interface MixerState { + channels: MixerChannelState[]; + buses: MixerBusState[]; + routes: MixerRouteState[]; +} + +export type WsStatus = + | "disconnected" + | "connecting" + | "connected" + | "error"; + +export interface MixerWsHandle { + /** Current connection status. */ + status: WsStatus; + /** Last error message (cleared on reconnect). */ + lastError: string | null; + /** Send a mixer command and await the reply. */ + send: (command: string, params?: unknown) => Promise; + /** Manually connect (auto-called on mount; safe to call again after disconnect). */ + connect: () => void; + /** Disconnect manually. */ + disconnect: () => void; +} + +// ── Defaults ─────────────────────────────────────────────────────── + +const DEFAULT_WS_URL = "ws://192.168.0.245:8080/ws"; +const MAX_RECONNECT_DELAY_MS = 30_000; +const INITIAL_RECONNECT_DELAY_MS = 1_000; + +// ── Hook ─────────────────────────────────────────────────────────── + +export function useMixerWS(wsUrl: string = DEFAULT_WS_URL): MixerWsHandle { + const [status, setStatus] = useState("disconnected"); + const [lastError, setLastError] = useState(null); + + const wsRef = useRef(null); + const replyMapRef = useRef void>>(new Map()); + const nextReplyToRef = useRef(0); + const reconnectTimerRef = useRef | null>(null); + const reconnectAttemptRef = useRef(0); + const mountedRef = useRef(true); + const wsUrlRef = useRef(wsUrl); + + // Keep the URL ref current without triggering re-renders. + useEffect(() => { + wsUrlRef.current = wsUrl; + }, [wsUrl]); + + /** Clean up WS and timers. */ + const cleanup = useCallback(() => { + // Clear reconnect timer. + if (reconnectTimerRef.current !== null) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + + // Close socket cleanly. + const sock = wsRef.current; + if (sock) { + sock.onopen = null; + sock.onclose = null; + sock.onerror = null; + sock.onmessage = null; + if (sock.readyState === WebSocket.OPEN || sock.readyState === WebSocket.CONNECTING) { + sock.close(); + } + wsRef.current = null; + } + + // Reject all pending promises. + const pending = replyMapRef.current; + if (pending.size > 0) { + const err = new Error("WebSocket disconnected"); + for (const reject of pending.values()) { + reject(err); + } + pending.clear(); + } + }, []); + + /** Open a new WebSocket connection. */ + const connect = useCallback(() => { + if (!mountedRef.current) return; + if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) { + return; // already connected or connecting + } + + cleanup(); + setStatus("connecting"); + setLastError(null); + + const url = wsUrlRef.current; + let sock: WebSocket; + + try { + sock = new WebSocket(url); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setStatus("error"); + setLastError(`Failed to create WebSocket: ${msg}`); + return; + } + + sock.onopen = () => { + if (!mountedRef.current) { sock.close(); return; } + wsRef.current = sock; + reconnectAttemptRef.current = 0; + setStatus("connected"); + setLastError(null); + }; + + sock.onclose = () => { + if (!mountedRef.current) return; + if (wsRef.current === sock) { + wsRef.current = null; + } + setStatus("disconnected"); + // Schedule reconnect with exponential backoff. + scheduleReconnect(); + }; + + sock.onerror = () => { + // The onclose event will fire after onerror, so we don't set + // status here to avoid a flash of "error" followed by "disconnected". + setLastError("WebSocket connection error"); + }; + + sock.onmessage = (event: MessageEvent) => { + if (!mountedRef.current) return; + + let parsed: unknown; + try { + parsed = JSON.parse(event.data); + } catch { + // Ignore non-JSON messages. + return; + } + + if (!Array.isArray(parsed)) return; + if (parsed.length < 1) return; + + const header = parsed[0] as Record; + const body = parsed.length >= 2 ? parsed[1] : undefined; + + // If this is a reply to one of our requests, resolve the promise. + if (typeof header.reply === "number") { + const resolve = replyMapRef.current.get(header.reply); + if (resolve) { + replyMapRef.current.delete(header.reply); + if (header.message === "error") { + resolve(Promise.reject(new Error(String(body ?? "Unknown error")))); + } else { + resolve(body); + } + } + } + // Unprompted messages (state updates, etc.) are ignored at this level. + // Subscribers can extend the hook later if they need push events. + }; + + wsRef.current = sock; + }, [cleanup]); + + /** Schedule a reconnection attempt with exponential backoff. */ + const scheduleReconnect = useCallback(() => { + if (!mountedRef.current) return; + if (reconnectTimerRef.current !== null) return; // already scheduled + + const attempt = reconnectAttemptRef.current; + const delay = Math.min( + INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt), + MAX_RECONNECT_DELAY_MS + ); + reconnectAttemptRef.current = attempt + 1; + + reconnectTimerRef.current = setTimeout(() => { + reconnectTimerRef.current = null; + if (mountedRef.current) { + connect(); + } + }, delay); + }, [connect]); + + /** Send a command and wait for the response. */ + const send = useCallback((command: string, params?: unknown): Promise => { + return new Promise((resolve, reject) => { + const sock = wsRef.current; + if (!sock || sock.readyState !== WebSocket.OPEN) { + reject(new Error("WebSocket not connected")); + return; + } + + const replyTo = ++nextReplyToRef.current; + + // Build message in PiPedalSocket format: [header, body?] + const header: Record = { message: command, replyTo }; + const msg = params !== undefined ? [header, params] : [header]; + + // Register the reply handler before sending (race-safe). + replyMapRef.current.set(replyTo, (value: unknown) => { + resolve(value); + }); + + // Set a timeout for the reply. + const timeout = setTimeout(() => { + replyMapRef.current.delete(replyTo); + reject(new Error(`Command "${command}" timed out after 10s`)); + }, 10_000); + + // Wrap the resolve to clear the timeout. + const originalResolve = replyMapRef.current.get(replyTo)!; + replyMapRef.current.set(replyTo, (value: unknown) => { + clearTimeout(timeout); + originalResolve(value); + }); + + try { + sock.send(JSON.stringify(msg)); + } catch (err: unknown) { + clearTimeout(timeout); + replyMapRef.current.delete(replyTo); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + }, []); + + const disconnect = useCallback(() => { + cleanup(); + setStatus("disconnected"); + }, [cleanup]); + + // ── Lifecycle ────────────────────────────────────────────────── + + useEffect(() => { + mountedRef.current = true; + // Connect on mount. + connect(); + + return () => { + mountedRef.current = false; + cleanup(); + }; + }, [connect, cleanup]); + + return { status, lastError, send, connect, disconnect }; +}