Wire MetersPage into app with gate/compressor indicators
- App.tsx: Import and render MetersPage when 'Meters' button toggled; add Meters button to header between Patch and PiPedal buttons - useMixerDaemon.ts: Add gateOpen and compressorReduction fields to MixerChannel interface - MetersPage.tsx: Add gate open/closed indicator (green/red G badge) and compressor gain reduction bar to ChannelMeterTile - MetersPage.css: Add gate indicator and compressor GR track styles
This commit is contained in:
-1
File diff suppressed because one or more lines are too long
+42
File diff suppressed because one or more lines are too long
-42
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+372
-11
@@ -1,19 +1,380 @@
|
||||
import React from 'react';
|
||||
import { usePiPedalWS } from './hooks/usePiPedalWS';
|
||||
import { MixerPage } from './components/MixerPage/MixerPage';
|
||||
import React, { useState } from 'react';
|
||||
import { useMixerDaemon, MixerChannel, MixerBus, MixerRoute } from './hooks/useMixerDaemon';
|
||||
import { useMixerScenes } from './hooks/useMixerScenes';
|
||||
import { useChannelPresets } from './hooks/useChannelPresets';
|
||||
import { DaemonSceneManager } from './components/DaemonSceneManager/DaemonSceneManager';
|
||||
import { ConnectionStatus } from './components/ConnectionStatus';
|
||||
import { PatchBay } from './components/PatchBay/PatchBay';
|
||||
import { useJackPorts } from './hooks/useJackPorts';
|
||||
import { MetersPage } from './components/MetersPage/MetersPage';
|
||||
import './App.css';
|
||||
|
||||
// ── Channel Preset Panel ────────────────────────────────────
|
||||
|
||||
const ChannelPresetPanel: React.FC<{
|
||||
channelIndex: number;
|
||||
channelLabel: string;
|
||||
presets: { id: string; name: string; channelIndex: number | null; createdAt: number }[];
|
||||
loading: boolean;
|
||||
onSave: (name: string) => void;
|
||||
onLoad: (presetId: string) => void;
|
||||
onDelete: (presetId: string) => void;
|
||||
onClose: () => void;
|
||||
}> = ({ channelIndex, channelLabel, presets, loading, onSave, onLoad, onDelete, onClose }) => {
|
||||
const [presetName, setPresetName] = useState('');
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = presetName.trim();
|
||||
if (!trimmed) return;
|
||||
onSave(trimmed);
|
||||
setPresetName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="preset-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="preset-panel-header">
|
||||
<span className="preset-panel-title">Presets — {channelLabel}</span>
|
||||
<button className="preset-panel-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="preset-panel-list">
|
||||
{loading && presets.length === 0 && (
|
||||
<div className="preset-panel-empty">Loading...</div>
|
||||
)}
|
||||
{!loading && presets.length === 0 && (
|
||||
<div className="preset-panel-empty">No presets saved</div>
|
||||
)}
|
||||
{presets.map((p) => (
|
||||
<div key={p.id} className="preset-row">
|
||||
<span className="preset-row-name">{p.name}</span>
|
||||
<div className="preset-row-actions">
|
||||
<button
|
||||
className="preset-row-btn load"
|
||||
onClick={() => onLoad(p.id)}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
className="preset-row-btn delete"
|
||||
onClick={() => onDelete(p.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="preset-panel-save">
|
||||
<input
|
||||
className="preset-panel-input"
|
||||
type="text"
|
||||
placeholder="Preset name..."
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
maxLength={60}
|
||||
/>
|
||||
<button
|
||||
className="preset-panel-save-btn"
|
||||
onClick={handleSave}
|
||||
disabled={!presetName.trim()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Channel Strip ──────────────────────────────────────────
|
||||
|
||||
const ChannelStrip: React.FC<{
|
||||
ch: MixerChannel;
|
||||
onVolume: (db: number) => void;
|
||||
onPan: (pan: number) => void;
|
||||
onMute: () => void;
|
||||
onSolo: () => void;
|
||||
onPresets: () => void;
|
||||
}> = ({ ch, onVolume, onPan, onMute, onSolo, onPresets }) => (
|
||||
<div className={`daemon-channel ${ch.mute ? 'muted' : ''}`}>
|
||||
<div className="daemon-channel-header">
|
||||
<span className="daemon-channel-label">{ch.label}</span>
|
||||
<span className="daemon-channel-type">{ch.type}</span>
|
||||
</div>
|
||||
<div className="daemon-channel-controls">
|
||||
<label>
|
||||
Vol
|
||||
<input type="range" min="-96" max="12" step="0.5" value={ch.volume}
|
||||
onChange={(e) => onVolume(parseFloat(e.target.value))} />
|
||||
<span className="daemon-value">{ch.volume.toFixed(1)}dB</span>
|
||||
</label>
|
||||
<label>
|
||||
Pan
|
||||
<input type="range" min="-1" max="1" step="0.1" value={ch.pan}
|
||||
onChange={(e) => onPan(parseFloat(e.target.value))} />
|
||||
<span className="daemon-value">{ch.pan.toFixed(1)}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="daemon-channel-buttons">
|
||||
<button className={`daemon-btn ${ch.mute ? 'active' : ''}`} onClick={onMute}>M</button>
|
||||
<button className={`daemon-btn ${ch.solo ? 'active' : ''}`} onClick={onSolo}>S</button>
|
||||
<button className="daemon-btn preset-btn" onClick={onPresets} title="Channel presets">P</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Bus Strip ──────────────────────────────────────────────
|
||||
|
||||
const BusStrip: React.FC<{ bus: MixerBus; channels: MixerChannel[]; routes: MixerRoute[] }> = ({
|
||||
bus, channels, routes,
|
||||
}) => {
|
||||
const channelRoutes = routes.filter((r) => r.targetBusId === bus.id && r.sourceType === 'channel');
|
||||
|
||||
return (
|
||||
<div className="daemon-bus">
|
||||
<div className="daemon-channel-header">
|
||||
<span className="daemon-channel-label">{bus.name}</span>
|
||||
<span className="daemon-channel-type">{bus.type}</span>
|
||||
</div>
|
||||
<div className="daemon-bus-meters">
|
||||
<span className="daemon-value">{bus.volume.toFixed(1)}dB</span>
|
||||
{bus.mute && <span className="daemon-badge muted-badge">MUTED</span>}
|
||||
</div>
|
||||
{channelRoutes.length > 0 && (
|
||||
<div className="daemon-bus-routes">
|
||||
<div className="daemon-bus-routes-title">Inputs</div>
|
||||
{channelRoutes.map((r) => {
|
||||
const ch = channels.find((c) => c.channelIndex === r.sourceId || c.channelIndex + 3 === r.sourceId);
|
||||
const name = ch ? ch.label : `Ch ${r.sourceId}`;
|
||||
return (
|
||||
<div key={`${r.sourceId}→${r.targetBusId}`} className="daemon-bus-route-row">
|
||||
<span className="daemon-route-name">{name}</span>
|
||||
<span className="daemon-route-level">{r.level.toFixed(1)}dB</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Master Strip ───────────────────────────────────────────
|
||||
|
||||
const MasterStrip: React.FC<{ bus: MixerBus }> = ({ bus }) => (
|
||||
<div className="daemon-master">
|
||||
<div className="daemon-channel-header">
|
||||
<span className="daemon-channel-label">{bus.name}</span>
|
||||
</div>
|
||||
<div className="daemon-bus-meters">
|
||||
<span className="daemon-value">{bus.volume.toFixed(1)}dB</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── App ────────────────────────────────────────────────────
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { connectionState, plugins, pedalboardName, discover, lastUpdate } = usePiPedalWS();
|
||||
const { state, loading, error, refresh, setVolume, setPan, setMute, setSolo } = useMixerDaemon();
|
||||
const { scenes, loading: scenesLoading, error: scenesError, saveScene, loadScene, deleteScene, refresh: refreshScenes } = useMixerScenes();
|
||||
const { presets, loading: presetsLoading, error: presetsError, savePreset, loadPreset, deletePreset, refreshPresets } = useChannelPresets();
|
||||
const { portsState, refresh: refreshJack, connect, disconnect, disconnectAll, actionState, actionError } = useJackPorts();
|
||||
|
||||
const [showScenes, setShowScenes] = useState(false);
|
||||
const [showMeters, setShowMeters] = useState(false);
|
||||
const [showPiPedal, setShowPiPedal] = useState(false);
|
||||
const [showPatchBay, setShowPatchBay] = useState(false);
|
||||
const [presetChannel, setPresetChannel] = useState<number | null>(null);
|
||||
|
||||
const pipedalUrl = 'http://192.168.0.245:8080/';
|
||||
|
||||
// Load presets when a channel's preset panel opens
|
||||
const handlePresetOpen = (channelIndex: number) => {
|
||||
setPresetChannel(channelIndex);
|
||||
refreshPresets(channelIndex);
|
||||
};
|
||||
|
||||
const handleSavePreset = async (name: string) => {
|
||||
if (presetChannel === null) return;
|
||||
// Get current state and extract this channel's params
|
||||
if (!state) return;
|
||||
const ch = state.channels.find((c) => c.channelIndex === presetChannel);
|
||||
if (!ch) return;
|
||||
await savePreset(presetChannel, name, {
|
||||
volume: ch.volume,
|
||||
pan: ch.pan,
|
||||
mute: ch.mute,
|
||||
solo: ch.solo,
|
||||
});
|
||||
};
|
||||
|
||||
const channelPresets = presetChannel !== null
|
||||
? presets.filter((p) => p.channelIndex === presetChannel || p.channelIndex === null)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<MixerPage
|
||||
plugins={plugins}
|
||||
connectionState={connectionState}
|
||||
pedalboardName={pedalboardName}
|
||||
lastUpdate={lastUpdate}
|
||||
onRefresh={discover}
|
||||
/>
|
||||
{/* Header */}
|
||||
<header className="mixer-header">
|
||||
<div className="mixer-header-left">
|
||||
<h1 className="mixer-title">OPLabs Mixer</h1>
|
||||
{state && <span className="mixer-pedalboard-name">{state.physicalInputCount}ch / {state.sampleRate}Hz</span>}
|
||||
</div>
|
||||
<div className="mixer-header-right">
|
||||
<ConnectionStatus
|
||||
state={{ status: error ? 'error' : loading ? 'connecting' : 'connected', error: error || undefined }}
|
||||
onRetry={refresh}
|
||||
/>
|
||||
<button
|
||||
className={`mixer-scenes-btn ${showScenes ? 'active' : ''}`}
|
||||
onClick={() => setShowScenes((v) => !v)}
|
||||
>
|
||||
🎬 Scenes
|
||||
</button>
|
||||
<button className={`mixer-scenes-btn ${showPatchBay ? 'active' : ''}`} onClick={() => setShowPatchBay((v) => !v)}>
|
||||
🔌 Patch
|
||||
</button>
|
||||
<button className={`mixer-scenes-btn ${showMeters ? 'active' : ''}`} onClick={() => setShowMeters((v) => !v)}>
|
||||
📊 Meters
|
||||
</button>
|
||||
<button className={`mixer-scenes-btn ${showPiPedal ? 'active' : ''}`} onClick={() => setShowPiPedal((v) => !v)}>
|
||||
🎛️ PiPedal
|
||||
</button>
|
||||
<button className="mixer-refresh-btn" onClick={refresh}>↻ Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
{showMeters ? (
|
||||
<MetersPage
|
||||
state={state}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
onBack={() => setShowMeters(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className={`mixer-content ${showPiPedal ? 'mixer-content-split' : ''}`}>
|
||||
{/* Mixer controls */}
|
||||
<div className="mixer-content-main">
|
||||
{loading && <div className="mixer-empty"><p>Connecting to mixer daemon...</p></div>}
|
||||
|
||||
{error && (
|
||||
<div className="mixer-empty">
|
||||
<h2>Mixer Daemon Unreachable</h2>
|
||||
<p>{error}</p>
|
||||
<button className="mixer-refresh-btn large" onClick={refresh}>↻ Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && state.channels.length === 0 && (
|
||||
<div className="mixer-empty">
|
||||
<h2>No Channels</h2>
|
||||
<p>No mixer channels configured. Add channels via the daemon.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && state.channels.length > 0 && (
|
||||
<>
|
||||
{/* Channel strips */}
|
||||
<div className="daemon-channels-section">
|
||||
<div className="mixer-section-header">
|
||||
<h2>Channels</h2>
|
||||
<span className="mixer-count">{state.channels.length}</span>
|
||||
</div>
|
||||
<div className="daemon-channels-grid">
|
||||
{state.channels.map((ch) => (
|
||||
<div key={ch.channelIndex} className="daemon-channel-wrapper">
|
||||
<ChannelStrip
|
||||
ch={ch}
|
||||
onVolume={(db) => setVolume(ch.channelIndex, db)}
|
||||
onPan={(p) => setPan(ch.channelIndex, p)}
|
||||
onMute={() => setMute(ch.channelIndex, !ch.mute)}
|
||||
onSolo={() => setSolo(ch.channelIndex, !ch.solo)}
|
||||
onPresets={() => handlePresetOpen(ch.channelIndex)}
|
||||
/>
|
||||
{/* Preset panel for this channel */}
|
||||
{presetChannel === ch.channelIndex && (
|
||||
<ChannelPresetPanel
|
||||
channelIndex={ch.channelIndex}
|
||||
channelLabel={ch.label}
|
||||
presets={channelPresets}
|
||||
loading={presetsLoading}
|
||||
onSave={handleSavePreset}
|
||||
onLoad={(presetId) => loadPreset(ch.channelIndex, presetId)}
|
||||
onDelete={(presetId) => deletePreset(ch.channelIndex, presetId)}
|
||||
onClose={() => setPresetChannel(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buses with routing */}
|
||||
{state.buses.map((bus) => (
|
||||
<div key={bus.id} className="daemon-buses-section">
|
||||
{bus.type === 'Master' ? (
|
||||
<MasterStrip bus={bus} />
|
||||
) : (
|
||||
<BusStrip bus={bus} channels={state.channels} routes={state.routes} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Output routing */}
|
||||
{state.outputRoutes && state.outputRoutes.length > 0 && (
|
||||
<div className="daemon-routing-info">
|
||||
<div className="mixer-section-header">
|
||||
<h2>Output Routing</h2>
|
||||
</div>
|
||||
<div className="daemon-routing-grid">
|
||||
{state.outputRoutes.map((r, i) => (
|
||||
<div key={i} className="daemon-route-chip">
|
||||
Bus {r.sourceBusId} → Out {r.targetStartChannel}+{r.channels - 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PiPedal iframe */}
|
||||
{showPiPedal && (
|
||||
<div className="mixer-pipedal-pane">
|
||||
<iframe src={pipedalUrl} title="PiPedal" className="mixer-pipedal-iframe"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showScenes && (
|
||||
<DaemonSceneManager
|
||||
scenes={scenes}
|
||||
loading={scenesLoading}
|
||||
error={scenesError}
|
||||
onClose={() => setShowScenes(false)}
|
||||
onSaveScene={saveScene}
|
||||
onLoadScene={loadScene}
|
||||
onDeleteScene={deleteScene}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Patch Bay */}
|
||||
{showPatchBay && (
|
||||
<div className="mixer-patch-pane">
|
||||
<PatchBay
|
||||
portsState={portsState}
|
||||
onConnect={connect}
|
||||
onDisconnect={disconnect}
|
||||
onDisconnectAll={disconnectAll}
|
||||
onRefresh={refreshJack}
|
||||
actionState={actionState}
|
||||
actionError={actionError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
/* ── Meter Bridge – full‑screen layout ─────────────── */
|
||||
|
||||
.meters-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: #0d0d1a;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Cooper Hewitt', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
padding: 12px 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────────────────── */
|
||||
|
||||
.meters-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #222;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meters-back-btn {
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #333;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.meters-back-btn:hover {
|
||||
border-color: #e94560;
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
}
|
||||
|
||||
.meters-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.meters-count {
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
background: #1a1a2e;
|
||||
padding: 2px 9px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meters-refresh-btn {
|
||||
margin-left: auto;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #333;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.meters-refresh-btn:hover {
|
||||
border-color: #e94560;
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
}
|
||||
|
||||
/* ── Empty state ───────────────────────────────────── */
|
||||
|
||||
.meters-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.meters-empty h2 {
|
||||
font-size: 1.2rem;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.meters-empty p {
|
||||
font-size: 0.9rem;
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.meters-retry-btn {
|
||||
background: #e94560;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Sections ──────────────────────────────────────── */
|
||||
|
||||
.meters-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meters-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.meters-section-header h2 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.meters-section-count {
|
||||
font-size: 0.6rem;
|
||||
color: #666;
|
||||
background: #1a1a2e;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Channels grid ─────────────────────────────────── */
|
||||
|
||||
.meters-grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── Meter tile (channel / bus / master) ───────────── */
|
||||
|
||||
.meter-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #111122;
|
||||
border: 1px solid #222;
|
||||
border-radius: 8px;
|
||||
padding: 8px 6px 6px;
|
||||
min-width: 72px;
|
||||
max-width: 80px;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.meter-tile:hover {
|
||||
border-color: #444;
|
||||
}
|
||||
.meter-tile--muted {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.meter-tile--bus {
|
||||
min-width: 60px;
|
||||
max-width: 68px;
|
||||
padding: 6px 4px 4px;
|
||||
}
|
||||
|
||||
/* Badge (label + type) */
|
||||
.meter-tile-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
.meter-tile-name {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: #ddd;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.meter-tile-type {
|
||||
font-size: 0.5rem;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Status flags (M / S) */
|
||||
.meter-tile-status {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
min-height: 14px;
|
||||
}
|
||||
.meter-tile-flag {
|
||||
font-size: 0.5rem;
|
||||
font-weight: 800;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.flag-mute {
|
||||
background: #e94560;
|
||||
color: #fff;
|
||||
}
|
||||
.flag-solo {
|
||||
background: #ff9800;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Gate indicator */
|
||||
.meter-tile-gate {
|
||||
font-size: 0.5rem;
|
||||
font-weight: 800;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.2;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.meter-tile-gate.gate-open {
|
||||
background: #4caf50;
|
||||
color: #fff;
|
||||
}
|
||||
.meter-tile-gate.gate-closed {
|
||||
background: #e94560;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Bars container */
|
||||
.meter-tile-bars {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Individual meter bar ──────────────────────────── */
|
||||
|
||||
.meter-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meter-bar--compact {
|
||||
gap: 1px;
|
||||
}
|
||||
.meter-bar--compact .meter-bar-track {
|
||||
min-height: 60px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.meter-bar-label {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.meter-bar-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 80px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 3px;
|
||||
border: 1px solid #2a2a2a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meter-bar-fill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transition: height 0.04s linear;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.meter-bar-peak {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 1px;
|
||||
transition: bottom 0.12s ease-out;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.meter-bar-tick {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
z-index: 1;
|
||||
}
|
||||
.meter-bar-tick--yellow {
|
||||
background: rgba(255, 152, 0, 0.18);
|
||||
}
|
||||
.meter-bar-tick--green {
|
||||
background: rgba(76, 175, 80, 0.12);
|
||||
}
|
||||
|
||||
.meter-bar-db {
|
||||
font-size: 0.55rem;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meter-bar-db.hot {
|
||||
color: #f44336;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Compressor gain reduction indicator ───────────── */
|
||||
|
||||
.meter-tile-comp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.meter-comp-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
.meter-comp-fill {
|
||||
height: 100%;
|
||||
background: #ff9800;
|
||||
border-radius: 1px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
.meter-comp-label {
|
||||
font-size: 0.45rem;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
color: #ff9800;
|
||||
white-space: nowrap;
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Bus grid (more compact) ───────────────────────── */
|
||||
|
||||
.meters-grid--bus {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Master output row ─────────────────────────────── */
|
||||
|
||||
.meters-master-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
background: #111122;
|
||||
border: 1px solid #e94560;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
max-width: 500px;
|
||||
}
|
||||
.meters-master-row .meter-bar {
|
||||
max-width: 60px;
|
||||
flex: 0 0 50px;
|
||||
}
|
||||
.meters-master-row .meter-bar-track {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.meters-master-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.meters-master-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: #e94560;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.meters-master-db {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* ── Scroll container for the whole page ───────────── */
|
||||
|
||||
.meters-page {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Responsive small screens ──────────────────────── */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.meters-page {
|
||||
padding: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
.meter-tile {
|
||||
min-width: 60px;
|
||||
max-width: 66px;
|
||||
padding: 6px 4px 4px;
|
||||
}
|
||||
.meter-tile-bars {
|
||||
min-height: 80px;
|
||||
}
|
||||
.meter-bar-track {
|
||||
min-height: 60px;
|
||||
}
|
||||
.meters-master-row {
|
||||
max-width: 100%;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.meters-master-row .meter-bar-track {
|
||||
min-height: 70px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import type { MixerChannel, MixerBus, MixerState } from '../../hooks/useMixerDaemon';
|
||||
import './MetersPage.css';
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────
|
||||
|
||||
const DB_MIN = -60;
|
||||
const DB_MAX = 0;
|
||||
|
||||
/**
|
||||
* Converts a dB value to a fill percentage (0–100).
|
||||
* Uses a sqrt curve so –20 dB appears around 50 % (VU-ish response).
|
||||
*/
|
||||
function dbToPercent(db: number): number {
|
||||
const clamped = Math.max(DB_MIN, Math.min(DB_MAX, db));
|
||||
const normalized = (clamped - DB_MIN) / (DB_MAX - DB_MIN);
|
||||
return Math.sqrt(normalized) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an HSL colour string for the given fill percentage.
|
||||
* Green (< 60 % of range) → hue ~120°
|
||||
* Yellow (60–85 %) → hue ~50°
|
||||
* Red (> 85 %) → hue ~0°
|
||||
*/
|
||||
function pctColor(pct: number): string {
|
||||
if (pct < 60) return `hsl(${120 - (pct / 60) * 70}, 85%, 45%)`;
|
||||
if (pct < 85) return `hsl(${50 - ((pct - 60) / 25) * 50}, 95%, 50%)`;
|
||||
return `hsl(0, 90%, 50%)`;
|
||||
}
|
||||
|
||||
// ── Sub‑components ─────────────────────────────────────────────
|
||||
|
||||
interface MeterBarProps {
|
||||
label: string;
|
||||
/** Current dB level ( –60 … 0 ) */
|
||||
level: number;
|
||||
/** Peak hold duration in ms (default 2000) */
|
||||
peakHold?: number;
|
||||
/** Compact variant (for buses / master) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single mono LED‑style meter bar with peak‑hold dot.
|
||||
*/
|
||||
const MeterBar: React.FC<MeterBarProps> = ({ label, level, peakHold = 2000, compact = false }) => {
|
||||
const peakRef = useRef<number>(DB_MIN);
|
||||
const peakTimer = useRef<number | null>(null);
|
||||
const [, forceUpdate] = React.useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (level > peakRef.current) {
|
||||
peakRef.current = level;
|
||||
if (peakTimer.current !== null) clearTimeout(peakTimer.current);
|
||||
peakTimer.current = window.setTimeout(() => {
|
||||
peakRef.current = Math.max(level, DB_MIN);
|
||||
forceUpdate((n) => n + 1);
|
||||
}, peakHold);
|
||||
}
|
||||
return () => {
|
||||
if (peakTimer.current !== null) clearTimeout(peakTimer.current);
|
||||
};
|
||||
}, [level, peakHold]);
|
||||
|
||||
const pct = dbToPercent(level);
|
||||
const peakPct = dbToPercent(peakRef.current);
|
||||
|
||||
return (
|
||||
<div className={`meter-bar ${compact ? 'meter-bar--compact' : ''}`}>
|
||||
<span className="meter-bar-label">{label}</span>
|
||||
<div className="meter-bar-track">
|
||||
<div
|
||||
className="meter-bar-fill"
|
||||
style={{ height: `${Math.min(100, pct)}%`, backgroundColor: pctColor(pct) }}
|
||||
/>
|
||||
{/* Peak hold dot */}
|
||||
<div className="meter-bar-peak" style={{ bottom: `${Math.min(100, peakPct)}%` }} />
|
||||
{/* Reference ticks */}
|
||||
<div className="meter-bar-tick" style={{ bottom: `${dbToPercent(-6)}%` }} />
|
||||
<div className="meter-bar-tick meter-bar-tick--yellow" style={{ bottom: `${dbToPercent(-20)}%` }} />
|
||||
<div className="meter-bar-tick meter-bar-tick--green" style={{ bottom: `${dbToPercent(-40)}%` }} />
|
||||
</div>
|
||||
<span className={`meter-bar-db ${level > -10 ? 'hot' : ''}`}>
|
||||
{level > DB_MIN ? `${level >= 0 ? '+' : ''}${level.toFixed(1)}` : '-∞'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Tile components ────────────────────────────────────────────
|
||||
|
||||
interface ChannelMeterTileProps {
|
||||
channel: MixerChannel;
|
||||
}
|
||||
|
||||
/**
|
||||
* A large meter tile for a single channel – stereo bars,
|
||||
* name badge, mute / solo status, gate indicator, and compressor GR.
|
||||
*/
|
||||
const ChannelMeterTile: React.FC<ChannelMeterTileProps> = ({ channel }) => {
|
||||
const l = channel.vuLeft ?? DB_MIN;
|
||||
const r = channel.vuRight ?? DB_MIN;
|
||||
const gateOpen = channel.gateOpen ?? true;
|
||||
const compRed = channel.compressorReduction ?? 0;
|
||||
|
||||
return (
|
||||
<div className={`meter-tile ${channel.mute ? 'meter-tile--muted' : ''}`}>
|
||||
<div className="meter-tile-badge">
|
||||
<span className="meter-tile-name">{channel.label || `Ch ${channel.channelIndex + 1}`}</span>
|
||||
<span className="meter-tile-type">{channel.type}</span>
|
||||
</div>
|
||||
<div className="meter-tile-status">
|
||||
{channel.mute && <span className="meter-tile-flag flag-mute">M</span>}
|
||||
{channel.solo && <span className="meter-tile-flag flag-solo">S</span>}
|
||||
<span className={`meter-tile-gate ${gateOpen ? 'gate-open' : 'gate-closed'}`}
|
||||
title={gateOpen ? 'Gate: Open' : 'Gate: Closed'}>
|
||||
G
|
||||
</span>
|
||||
</div>
|
||||
{/* Compressor gain reduction meter */}
|
||||
{compRed > 0.5 && (
|
||||
<div className="meter-tile-comp">
|
||||
<div className="meter-comp-track">
|
||||
<div className="meter-comp-fill" style={{ width: `${Math.min(100, compRed * 8)}%` }} />
|
||||
</div>
|
||||
<span className="meter-comp-label">{compRed.toFixed(1)}dB</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="meter-tile-bars">
|
||||
<MeterBar label="L" level={l} />
|
||||
<MeterBar label="R" level={r} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface BusMeterTileProps {
|
||||
bus: MixerBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact meter tile for a bus (or master).
|
||||
*/
|
||||
const BusMeterTile: React.FC<BusMeterTileProps> = ({ bus }) => {
|
||||
const l = bus.vuLeft ?? DB_MIN;
|
||||
const r = bus.vuRight ?? DB_MIN;
|
||||
return (
|
||||
<div className={`meter-tile meter-tile--bus ${bus.mute ? 'meter-tile--muted' : ''}`}>
|
||||
<div className="meter-tile-badge">
|
||||
<span className="meter-tile-name">{bus.name || `Bus ${bus.id}`}</span>
|
||||
<span className="meter-tile-type">{bus.type}</span>
|
||||
</div>
|
||||
<div className="meter-tile-status">
|
||||
{bus.mute && <span className="meter-tile-flag flag-mute">M</span>}
|
||||
</div>
|
||||
<div className="meter-tile-bars">
|
||||
<MeterBar label="L" level={l} compact />
|
||||
<MeterBar label="R" level={r} compact />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Main page component ────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
state: MixerState | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated meter‑bridge page — full‑screen VU meter display for
|
||||
* all mixer channels, buses, and the master output.
|
||||
*
|
||||
* Designed to be readable at a glance from across the room,
|
||||
* like a hardware mixing console's meter bridge.
|
||||
*/
|
||||
export const MetersPage: React.FC<Props> = ({ state, loading, error, onRefresh, onBack }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="meters-page">
|
||||
<div className="meters-empty">
|
||||
<p>Connecting to mixer daemon…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="meters-page">
|
||||
<div className="meters-empty">
|
||||
<h2>Mixer Daemon Unreachable</h2>
|
||||
<p>{error}</p>
|
||||
<button className="meters-retry-btn" onClick={onRefresh}>↻ Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<div className="meters-page">
|
||||
<div className="meters-empty">
|
||||
<p>No mixer data.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { channels, buses } = state;
|
||||
// Derive master level from buses (best available if daemon doesn't report master VU)
|
||||
const masterL = buses.length > 0
|
||||
? Math.max(...buses.map((b) => b.vuLeft ?? DB_MIN))
|
||||
: DB_MIN;
|
||||
const masterR = buses.length > 0
|
||||
? Math.max(...buses.map((b) => b.vuRight ?? DB_MIN))
|
||||
: DB_MIN;
|
||||
|
||||
return (
|
||||
<div className="meters-page">
|
||||
{/* Toolbar */}
|
||||
<div className="meters-toolbar">
|
||||
<button className="meters-back-btn" onClick={onBack} title="Back to mixer">
|
||||
← Back
|
||||
</button>
|
||||
<span className="meters-title">Meter Bridge</span>
|
||||
<span className="meters-count">{channels.length}ch</span>
|
||||
<button className="meters-refresh-btn" onClick={onRefresh}>↻</button>
|
||||
</div>
|
||||
|
||||
{/* Channel meters */}
|
||||
{channels.length > 0 && (
|
||||
<div className="meters-section">
|
||||
<div className="meters-section-header">
|
||||
<h2>Channels</h2>
|
||||
<span className="meters-section-count">{channels.length}</span>
|
||||
</div>
|
||||
<div className="meters-grid">
|
||||
{channels.map((ch) => (
|
||||
<ChannelMeterTile key={ch.channelIndex} channel={ch} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bus meters (non-master) */}
|
||||
{buses.filter((b) => b.type !== 'Master').length > 0 && (
|
||||
<div className="meters-section">
|
||||
<div className="meters-section-header">
|
||||
<h2>Buses</h2>
|
||||
<span className="meters-section-count">{buses.filter((b) => b.type !== 'Master').length}</span>
|
||||
</div>
|
||||
<div className="meters-grid meters-grid--bus">
|
||||
{buses
|
||||
.filter((b) => b.type !== 'Master')
|
||||
.map((bus) => (
|
||||
<BusMeterTile key={bus.id} bus={bus} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Master output — always shown when there's data */}
|
||||
<div className="meters-section">
|
||||
<div className="meters-section-header">
|
||||
<h2>Master</h2>
|
||||
</div>
|
||||
<div className="meters-master-row">
|
||||
<MeterBar label="L" level={masterL} peakHold={3000} />
|
||||
<MeterBar label="R" level={masterR} peakHold={3000} />
|
||||
<div className="meters-master-info">
|
||||
<span className="meters-master-label">Master Out</span>
|
||||
<span className="meters-master-db">
|
||||
{Math.max(masterL, masterR) > DB_MIN
|
||||
? `${Math.max(masterL, masterR).toFixed(1)} dB`
|
||||
: '-∞'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/** A single channel strip as reported by the mixer daemon. */
|
||||
export interface MixerChannel {
|
||||
channelIndex: number;
|
||||
label: string;
|
||||
volume: number; // dB
|
||||
pan: number; // -1.0 (left) to 1.0 (right)
|
||||
mute: boolean;
|
||||
solo: boolean;
|
||||
type: string; // "Instrument" | "Mic" | "Line"
|
||||
hpEnabled: boolean;
|
||||
hpFrequency: number;
|
||||
/** VU meter level for left channel in dB (-60 .. 0). Optional — daemon may not populate this. */
|
||||
vuLeft?: number;
|
||||
/** VU meter level for right channel in dB (-60 .. 0). Optional — daemon may not populate this. */
|
||||
vuRight?: number;
|
||||
/** Gate open (true = signal passing, false = gated/closed). Optional — daemon may not populate this. */
|
||||
gateOpen?: boolean;
|
||||
/** Compressor gain reduction in dB (0 = no reduction, higher = more reduction). Optional. */
|
||||
compressorReduction?: number;
|
||||
}
|
||||
|
||||
/** A bus as reported by the mixer daemon. */
|
||||
export interface MixerBus {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
volume: number;
|
||||
mute: boolean;
|
||||
/** VU meter level for left channel in dB (-60 .. 0). Optional. */
|
||||
vuLeft?: number;
|
||||
/** VU meter level for right channel in dB (-60 .. 0). Optional. */
|
||||
vuRight?: number;
|
||||
}
|
||||
|
||||
/** Full mixer state from GET /api/mixer/state */
|
||||
export interface MixerRoute {
|
||||
sourceId: number;
|
||||
sourceType: string;
|
||||
targetBusId: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface MixerState {
|
||||
channels: MixerChannel[];
|
||||
buses: MixerBus[];
|
||||
routes: MixerRoute[];
|
||||
outputRoutes: { sourceBusId: number; sourceStartChannel: number; targetStartChannel: number; channels: number }[];
|
||||
sampleRate: number;
|
||||
physicalInputCount: number;
|
||||
physicalOutputCount: number;
|
||||
}
|
||||
|
||||
export interface UseMixerDaemonReturn {
|
||||
state: MixerState | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
setVolume: (idx: number, db: number) => Promise<void>;
|
||||
setPan: (idx: number, pan: number) => Promise<void>;
|
||||
setMute: (idx: number, mute: boolean) => Promise<void>;
|
||||
setSolo: (idx: number, solo: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
function apiUrl(path: string): string {
|
||||
const base = window.location.origin;
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
async function apiPost(path: string, body: Record<string, unknown>): Promise<void> {
|
||||
const res = await fetch(apiUrl(path), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that polls the mixer daemon state via /api/mixer/state and
|
||||
* provides control functions (volume, pan, mute, solo).
|
||||
*/
|
||||
export function useMixerDaemon(): UseMixerDaemonReturn {
|
||||
const [state, setState] = useState<MixerState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
const fetchState = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(apiUrl('/api/mixer/state'));
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const data: MixerState = await res.json();
|
||||
setState(data);
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Poll on mount + every 2s
|
||||
useEffect(() => {
|
||||
fetchState();
|
||||
intervalRef.current = window.setInterval(fetchState, 2000);
|
||||
return () => {
|
||||
if (intervalRef.current !== null) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [fetchState]);
|
||||
|
||||
const setVolume = useCallback(async (idx: number, db: number) => {
|
||||
await apiPost(`/api/mixer/channel/${idx}/volume`, { volumeDb: db });
|
||||
fetchState();
|
||||
}, [fetchState]);
|
||||
|
||||
const setPan = useCallback(async (idx: number, pan: number) => {
|
||||
await apiPost(`/api/mixer/channel/${idx}/pan`, { pan });
|
||||
fetchState();
|
||||
}, [fetchState]);
|
||||
|
||||
const setMute = useCallback(async (idx: number, mute: boolean) => {
|
||||
await apiPost(`/api/mixer/channel/${idx}/mute`, { mute });
|
||||
fetchState();
|
||||
}, [fetchState]);
|
||||
|
||||
const setSolo = useCallback(async (idx: number, solo: boolean) => {
|
||||
await apiPost(`/api/mixer/channel/${idx}/solo`, { solo });
|
||||
fetchState();
|
||||
}, [fetchState]);
|
||||
|
||||
return { state, loading, error, refresh: fetchState, setVolume, setPan, setMute, setSolo };
|
||||
}
|
||||
Reference in New Issue
Block a user