feat: add React mixer UI — MixerPage, ChannelStrip, MasterBus, useMixerWS hook
- 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).
This commit is contained in:
@@ -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
|
||||
<PerformanceView open={this.state.performanceView}
|
||||
onClose={() => { this.setState({ performanceView: false }); }}
|
||||
/>
|
||||
) : this.state.mixerView ? (
|
||||
<MixerPage onClose={() => { this.setState({ mixerView: false }); }} />
|
||||
) : (
|
||||
<div style={{
|
||||
position: "absolute", width: "100%", height: "100%", userSelect: "none",
|
||||
@@ -890,6 +896,17 @@ export
|
||||
</ListItemIcon>
|
||||
<ListItemText primary='Performance View' />
|
||||
</ListItemButton>
|
||||
<ListItemButton key='MixerView'
|
||||
onClick={(ev: any) => {
|
||||
ev.stopPropagation();
|
||||
this.hideDrawer(true);
|
||||
this.setState({ mixerView: true });
|
||||
}}>
|
||||
<ListItemIcon >
|
||||
<MusicNoteIcon color='inherit' className={classes.menuIcon} style={{ width: 24, height: 24 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary='Mixer Console' />
|
||||
</ListItemButton>
|
||||
</List>
|
||||
<Divider />
|
||||
<ListSubheader className="listSubheader" component="div" id="xnested-list-subheader" style={{ lineHeight: "24px", height: 24, background: "rgba(12,12,12,0.0)" }}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// ChannelStrip — per-input channel control for the mixer console
|
||||
//
|
||||
// Volume fader, pan, mute/solo buttons, channel label, and type badge.
|
||||
// Matches the op-pedal mixer engine's channel strip parameters.
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Slider from "@mui/material/Slider";
|
||||
import ToggleButton from "@mui/material/ToggleButton";
|
||||
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
|
||||
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 MusicNote from "@mui/icons-material/MusicNote";
|
||||
|
||||
import type { MixerChannelState } from "./useMixerWS";
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChannelStripProps {
|
||||
channel: MixerChannelState;
|
||||
/** Called when a parameter changes — the parent issues the WS command. */
|
||||
onVolumeChange: (channelIndex: number, volumeDb: number) => 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<ChannelStripProps> = ({
|
||||
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 (
|
||||
<StripRoot>
|
||||
{/* Channel label */}
|
||||
<LabelBox>
|
||||
<Typography variant="caption" noWrap sx={{ fontWeight: 600 }}>
|
||||
{channel.label || `Ch ${channel.channelIndex + 1}`}
|
||||
</Typography>
|
||||
</LabelBox>
|
||||
|
||||
{/* Type badge */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: "0.55rem",
|
||||
opacity: 0.5,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{channel.type}
|
||||
</Typography>
|
||||
|
||||
{/* Volume fader (vertical) */}
|
||||
<FaderBox>
|
||||
<ValueLabel variant="caption">{formatDb(channel.volume)}</ValueLabel>
|
||||
<Slider
|
||||
value={volPct}
|
||||
onChange={handleVol}
|
||||
orientation="vertical"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
sx={{ height: 100, flex: "0 0 auto" }}
|
||||
/>
|
||||
</FaderBox>
|
||||
|
||||
{/* Pan slider */}
|
||||
<Box sx={{ width: "100%", px: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontSize: "0.55rem", opacity: 0.5 }}>
|
||||
Pan
|
||||
</Typography>
|
||||
<Slider
|
||||
value={channel.pan}
|
||||
onChange={handlePan}
|
||||
min={-1}
|
||||
max={1}
|
||||
step={0.05}
|
||||
size="small"
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(v: number) => v === 0 ? "C" : v < 0 ? `L${Math.round(-v * 100)}` : `R${Math.round(v * 100)}`}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Mute / Solo buttons */}
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
value={[]}
|
||||
sx={{ gap: 0.5, "& .MuiToggleButton-root": { px: 1, py: 0, minWidth: 32, height: 28, fontSize: "0.7rem" } }}
|
||||
>
|
||||
<ToggleButton
|
||||
value="mute"
|
||||
selected={channel.mute}
|
||||
onChange={handleMute}
|
||||
color={channel.mute ? "error" : "standard"}
|
||||
>
|
||||
{channel.mute ? <VolumeOff fontSize="inherit" /> : <VolumeUp fontSize="inherit" />}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
value="solo"
|
||||
selected={channel.solo}
|
||||
onChange={handleSolo}
|
||||
color={channel.solo ? "warning" : "standard"}
|
||||
>
|
||||
<MusicNote fontSize="inherit" />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</StripRoot>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelStrip;
|
||||
@@ -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<MasterBusProps> = ({
|
||||
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 (
|
||||
<BusRoot>
|
||||
{/* Bus name */}
|
||||
<Typography variant="caption" noWrap sx={{ fontWeight: 700 }}>
|
||||
{bus.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: "0.55rem",
|
||||
opacity: 0.5,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{bus.type}
|
||||
</Typography>
|
||||
|
||||
{/* Volume fader */}
|
||||
<FaderBox>
|
||||
<ValueLabel variant="caption">{formatDb(bus.volume)}</ValueLabel>
|
||||
<Slider
|
||||
value={volPct}
|
||||
onChange={handleVol}
|
||||
orientation="vertical"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
sx={{ height: 100, flex: "0 0 auto" }}
|
||||
/>
|
||||
</FaderBox>
|
||||
|
||||
{/* Mute button */}
|
||||
<ToggleButton
|
||||
value="mute"
|
||||
selected={bus.mute}
|
||||
onChange={handleMute}
|
||||
size="small"
|
||||
color={bus.mute ? "error" : "standard"}
|
||||
sx={{ px: 1, py: 0, minWidth: 48, height: 28, fontSize: "0.7rem" }}
|
||||
>
|
||||
{bus.mute ? (
|
||||
<VolumeOff fontSize="inherit" />
|
||||
) : (
|
||||
<VolumeUp fontSize="inherit" />
|
||||
)}
|
||||
</ToggleButton>
|
||||
</BusRoot>
|
||||
);
|
||||
};
|
||||
|
||||
export default MasterBus;
|
||||
@@ -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<typeof useMixerWS>) {
|
||||
const [state, setState] = useState<MixerState | null>(null);
|
||||
const [fetchError, setFetchError] = useState<string | null>(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<MixerPageProps> = ({ 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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: (t) => t.palette.background.default,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<Toolbar>
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
Mixer Console
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={fetchState}
|
||||
disabled={ws.status !== "connected"}
|
||||
>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
opacity: 0.6,
|
||||
fontSize: "0.6rem",
|
||||
mr: 1,
|
||||
}}
|
||||
>
|
||||
{ws.status === "connected"
|
||||
? "● Connected"
|
||||
: ws.status === "connecting"
|
||||
? "◌ Connecting..."
|
||||
: "○ Disconnected"}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
|
||||
{/* Scene panel */}
|
||||
<Box sx={{ px: 2, py: 0.5, flex: "0 0 auto" }}>
|
||||
<MixerScenePanel ws={ws} disabled={ws.status !== "connected"} />
|
||||
</Box>
|
||||
|
||||
{/* Error banner */}
|
||||
{fetchError && (
|
||||
<Alert severity="warning" sx={{ mx: 1, my: 0.5, py: 0 }}>
|
||||
{fetchError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{ws.lastError && (
|
||||
<Alert severity="info" sx={{ mx: 1, my: 0.5, py: 0 }}>
|
||||
{ws.lastError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Strip area */}
|
||||
<ScrollContainer>
|
||||
{ws.status === "connecting" && !state && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2">Connecting to mixer...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ws.status === "connected" && state && (
|
||||
<StripRow>
|
||||
{/* Channel strips */}
|
||||
{channels.map((ch) => (
|
||||
<ChannelStrip
|
||||
key={ch.channelIndex}
|
||||
channel={ch}
|
||||
onVolumeChange={handleChannelVolume}
|
||||
onPanChange={handleChannelPan}
|
||||
onMuteToggle={handleChannelMute}
|
||||
onSoloToggle={handleChannelSolo}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Divider */}
|
||||
{channels.length > 0 && <StickyDivider />}
|
||||
|
||||
{/* Other buses (subgroups, aux, etc.) */}
|
||||
{otherBuses.map((bus) => (
|
||||
<MasterBus
|
||||
key={bus.id}
|
||||
bus={bus}
|
||||
onVolumeChange={handleBusVolume}
|
||||
onMuteToggle={handleBusMute}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Master bus */}
|
||||
{masterBus && (
|
||||
<>
|
||||
<StickyDivider />
|
||||
<MasterBus
|
||||
key={masterBus.id}
|
||||
bus={masterBus}
|
||||
onVolumeChange={handleBusVolume}
|
||||
onMuteToggle={handleBusMute}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{channels.length === 0 && otherBuses.length === 0 && !masterBus && (
|
||||
<Typography variant="body2" sx={{ opacity: 0.5, p: 4 }}>
|
||||
No mixer state received yet.
|
||||
</Typography>
|
||||
)}
|
||||
</StripRow>
|
||||
)}
|
||||
</ScrollContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MixerPage;
|
||||
@@ -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<MixerSceneInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Tooltip
|
||||
key={slot}
|
||||
title={
|
||||
isUsed
|
||||
? `Restore "${scene!.name}"`
|
||||
: `Save current state to slot ${slot + 1}`
|
||||
}
|
||||
>
|
||||
<Box
|
||||
onClick={() => 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 */}
|
||||
<Typography
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 1,
|
||||
left: 5,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
opacity: 0.35,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{slot + 1}
|
||||
</Typography>
|
||||
|
||||
{isUsed ? (
|
||||
<>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
textAlign: "center",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 62,
|
||||
color: "#d0b0f0",
|
||||
}}
|
||||
>
|
||||
{scene!.name}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography
|
||||
sx={{ fontSize: 10, opacity: 0.3, textAlign: "center" }}
|
||||
>
|
||||
Empty
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
[getSlotScene, handleSlotClick, disabled],
|
||||
);
|
||||
|
||||
// ── Main render ─────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||
{/* Header row */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
opacity: 0.6,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
Scenes
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.5, alignItems: "center" }}>
|
||||
<Tooltip title="Refresh scene list">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={fetchScenes}
|
||||
disabled={ws.status !== "connected" || disabled}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<Refresh sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Save current mixer state as a new scene">
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Save sx={{ fontSize: 14 }} />}
|
||||
onClick={handleSaveClick}
|
||||
disabled={ws.status !== "connected" || disabled}
|
||||
sx={{
|
||||
minHeight: 24,
|
||||
minWidth: 0,
|
||||
fontSize: 10,
|
||||
textTransform: "none",
|
||||
py: 0,
|
||||
px: 1,
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<Alert severity="warning" sx={{ py: 0, px: 1, fontSize: 11 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Scene slot grid */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: MAX_SCENES }, (_, i) => renderSlot(i))}
|
||||
</Box>
|
||||
|
||||
{/* ── Save dialog ─────────────────────────────────────────── */}
|
||||
<Dialog
|
||||
open={saveOpen}
|
||||
onClose={() => setSaveOpen(false)}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Save Mixer Scene</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Scene Name"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveConfirm();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setSaveOpen(false)} color="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveConfirm}
|
||||
variant="contained"
|
||||
disabled={!saveName.trim() || saving}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Load confirm dialog ─────────────────────────────────── */}
|
||||
<Dialog
|
||||
open={loadConfirm !== null}
|
||||
onClose={() => setLoadConfirm(null)}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Load Scene</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2">
|
||||
Restore scene{" "}
|
||||
<strong>{loadConfirm?.name}</strong>?
|
||||
<br />
|
||||
This will overwrite current mixer settings.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setLoadConfirm(null)} color="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleLoadConfirm} variant="contained" color="primary">
|
||||
Load
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
/** 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<WsStatus>("disconnected");
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const replyMapRef = useRef<Map<number, (value: unknown) => void>>(new Map());
|
||||
const nextReplyToRef = useRef(0);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, unknown>;
|
||||
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<unknown> => {
|
||||
return new Promise<unknown>((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<string, unknown> = { 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user