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 HelpOutlineIcon from './svg/ic_help_outline.svg?react';
|
||||||
import FxAmplifierIcon from './svg/fx_amplifier.svg?react';
|
import FxAmplifierIcon from './svg/fx_amplifier.svg?react';
|
||||||
import { PerformanceView } from './PerformanceView';
|
import { PerformanceView } from './PerformanceView';
|
||||||
|
import MusicNoteIcon from '@mui/icons-material/MusicNote';
|
||||||
|
import MixerPage from './mixer/MixerPage';
|
||||||
|
|
||||||
|
|
||||||
import DialogEx from './DialogEx';
|
import DialogEx from './DialogEx';
|
||||||
@@ -322,6 +324,7 @@ type AppState = {
|
|||||||
banks: BankIndex;
|
banks: BankIndex;
|
||||||
bankDisplayItems: number;
|
bankDisplayItems: number;
|
||||||
showStatusMonitor: boolean;
|
showStatusMonitor: boolean;
|
||||||
|
mixerView: boolean;
|
||||||
};
|
};
|
||||||
class MenuStackHandler implements IDialogStackable {
|
class MenuStackHandler implements IDialogStackable {
|
||||||
constructor(app: AppThemedBase) {
|
constructor(app: AppThemedBase) {
|
||||||
@@ -380,7 +383,8 @@ export
|
|||||||
editBankDialogOpen: false,
|
editBankDialogOpen: false,
|
||||||
zoomedControlOpen: false,
|
zoomedControlOpen: false,
|
||||||
bankDisplayItems: 5,
|
bankDisplayItems: 5,
|
||||||
showStatusMonitor: this.model_.showStatusMonitor.get()
|
showStatusMonitor: this.model_.showStatusMonitor.get(),
|
||||||
|
mixerView: false,
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -802,6 +806,8 @@ export
|
|||||||
<PerformanceView open={this.state.performanceView}
|
<PerformanceView open={this.state.performanceView}
|
||||||
onClose={() => { this.setState({ performanceView: false }); }}
|
onClose={() => { this.setState({ performanceView: false }); }}
|
||||||
/>
|
/>
|
||||||
|
) : this.state.mixerView ? (
|
||||||
|
<MixerPage onClose={() => { this.setState({ mixerView: false }); }} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute", width: "100%", height: "100%", userSelect: "none",
|
position: "absolute", width: "100%", height: "100%", userSelect: "none",
|
||||||
@@ -890,6 +896,17 @@ export
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary='Performance View' />
|
<ListItemText primary='Performance View' />
|
||||||
</ListItemButton>
|
</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>
|
</List>
|
||||||
<Divider />
|
<Divider />
|
||||||
<ListSubheader className="listSubheader" component="div" id="xnested-list-subheader" style={{ lineHeight: "24px", height: 24, background: "rgba(12,12,12,0.0)" }}
|
<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