Initial: Standalone mixer app with MixerPage, scene management, WS proxy
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--surface: #16213e;
|
||||
--surface-alt: #0f3460;
|
||||
--accent: #e94560;
|
||||
--text: #eaeaea;
|
||||
--text-dim: #9e9e9e;
|
||||
--border: #2a2a4a;
|
||||
--success: #4caf50;
|
||||
--warning: #ff9800;
|
||||
--error: #f44336;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- Connection Status ---- */
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--error);
|
||||
color: var(--error);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.retry-btn:hover {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ---- Footer ---- */
|
||||
|
||||
.app-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { usePiPedalWS } from './hooks/usePiPedalWS';
|
||||
import { MixerPage } from './components/MixerPage/MixerPage';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { connectionState, plugins, pedalboardName, discover, lastUpdate } = usePiPedalWS();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<MixerPage
|
||||
plugins={plugins}
|
||||
connectionState={connectionState}
|
||||
pedalboardName={pedalboardName}
|
||||
lastUpdate={lastUpdate}
|
||||
onRefresh={discover}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import type { ConnectionState } from '../hooks/usePiPedalWS';
|
||||
|
||||
interface Props {
|
||||
state: ConnectionState;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
connected: '#4caf50',
|
||||
connecting: '#ff9800',
|
||||
disconnected: '#9e9e9e',
|
||||
error: '#f44336',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
connected: 'Connected to PiPedal',
|
||||
connecting: 'Connecting...',
|
||||
disconnected: 'Disconnected',
|
||||
error: 'Connection Error',
|
||||
};
|
||||
|
||||
export const ConnectionStatus: React.FC<Props> = ({ state, onRetry }) => {
|
||||
return (
|
||||
<div className="connection-status">
|
||||
<div className="status-indicator">
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{ backgroundColor: statusColors[state.status] || '#9e9e9e' }}
|
||||
/>
|
||||
<span className="status-label">{statusLabels[state.status] || state.status}</span>
|
||||
</div>
|
||||
{state.error && (
|
||||
<div className="status-error">
|
||||
<span>{state.error}</span>
|
||||
{onRetry && (
|
||||
<button className="retry-btn" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
.bus-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--surface, #16213e);
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
border-radius: var(--radius, 8px);
|
||||
}
|
||||
|
||||
/* ---- Master Section ---- */
|
||||
|
||||
.bus-master-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.bus-master-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.bus-master-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text, #eaeaea);
|
||||
}
|
||||
|
||||
.bus-master-meter {
|
||||
flex: 1;
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.bus-master-fader {
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* ---- Bus buttons ---- */
|
||||
|
||||
.bus-btn {
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
border-radius: 3px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: var(--bg, #1a1a2e);
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bus-btn.mute.active {
|
||||
background: var(--error, #f44336);
|
||||
color: #fff;
|
||||
border-color: var(--error, #f44336);
|
||||
}
|
||||
|
||||
.bus-btn.mini {
|
||||
width: 18px;
|
||||
height: 16px;
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.bus-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ---- Divider ---- */
|
||||
|
||||
.bus-divider {
|
||||
height: 1px;
|
||||
background: var(--border, #2a2a4a);
|
||||
margin: 0 -4px;
|
||||
}
|
||||
|
||||
/* ---- Per-Channel ---- */
|
||||
|
||||
.bus-channels {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.bus-channel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 36px;
|
||||
max-width: 44px;
|
||||
padding: 6px 4px;
|
||||
background: var(--bg, #1a1a2e);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bus-channel-label {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bus-channel-meter {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bus-channel-fader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bus-channel-db {
|
||||
font-size: 0.45rem;
|
||||
font-family: monospace;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- Fader styles ---- */
|
||||
|
||||
.bus-fader {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--border, #2a2a4a);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.bus-fader::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 14px;
|
||||
background: #e94560;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bus-fader::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 14px;
|
||||
background: #e94560;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bus-fader.mini {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.bus-fader.mini::-webkit-slider-thumb {
|
||||
width: 8px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.bus-fader.mini::-moz-range-thumb {
|
||||
width: 8px;
|
||||
height: 10px;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import type { PluginInstance } from '../../hooks/usePiPedalWS';
|
||||
import { LevelMeter } from './LevelMeter';
|
||||
import './BusStrip.css';
|
||||
|
||||
interface Props {
|
||||
/** The OPLabsBandBus plugin instance */
|
||||
plugin: PluginInstance;
|
||||
/** Callback to set control values */
|
||||
onSetControl: (instanceId: number, key: string, value: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a dB value to a mini-fader position (0-100).
|
||||
*/
|
||||
function dbToSliderPos(db: number): number {
|
||||
const clamped = Math.max(-60, Math.min(6, db));
|
||||
return ((clamped + 60) / 66) * 100;
|
||||
}
|
||||
|
||||
function sliderPosToDb(pos: number): number {
|
||||
return (pos / 100) * 66 - 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bus meter bridge: per-channel mini faders and mute buttons + master section.
|
||||
*
|
||||
* The OPLabsBandBus plugin has 62 ports:
|
||||
* - ch1Vol..ch8Vol, ch1Pan..ch8Pan, ch1Mute..ch8Mute
|
||||
* - ch1LevelL..ch8LevelL, ch1LevelR..ch8LevelR
|
||||
* - masterVol, masterMute, masterLevelL, masterLevelR
|
||||
*/
|
||||
export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
|
||||
const cv = plugin.controlValues;
|
||||
|
||||
const masterVol = cv.masterVol ?? 0;
|
||||
const masterMute = (cv.masterMute ?? 0) >= 0.5;
|
||||
const masterLevelL = cv.masterLevelL ?? -60;
|
||||
const masterLevelR = cv.masterLevelR ?? -60;
|
||||
|
||||
const channels = Array.from({ length: 8 }, (_, i) => {
|
||||
const idx = i + 1;
|
||||
return {
|
||||
index: idx,
|
||||
vol: cv[`ch${idx}Vol`] ?? 0,
|
||||
mute: (cv[`ch${idx}Mute`] ?? 0) >= 0.5,
|
||||
levelL: cv[`ch${idx}LevelL`] ?? -60,
|
||||
levelR: cv[`ch${idx}LevelR`] ?? -60,
|
||||
volKey: `ch${idx}Vol` as const,
|
||||
muteKey: `ch${idx}Mute` as const,
|
||||
};
|
||||
});
|
||||
|
||||
const handleMasterVol = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const db = sliderPosToDb(parseFloat(e.target.value));
|
||||
onSetControl(plugin.instanceId, 'masterVol', parseFloat(db.toFixed(1)));
|
||||
},
|
||||
[plugin.instanceId, onSetControl]
|
||||
);
|
||||
|
||||
const toggleMasterMute = useCallback(() => {
|
||||
onSetControl(plugin.instanceId, 'masterMute', masterMute ? 0 : 1);
|
||||
}, [plugin.instanceId, masterMute, onSetControl]);
|
||||
|
||||
const handleChannelVol = useCallback(
|
||||
(chIdx: number, volKey: string, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const db = sliderPosToDb(parseFloat(e.target.value));
|
||||
onSetControl(plugin.instanceId, volKey, parseFloat(db.toFixed(1)));
|
||||
},
|
||||
[plugin.instanceId, onSetControl]
|
||||
);
|
||||
|
||||
const toggleChannelMute = useCallback(
|
||||
(chIdx: number, muteKey: string, currentlyMuted: boolean) => {
|
||||
onSetControl(plugin.instanceId, muteKey, currentlyMuted ? 0 : 1);
|
||||
},
|
||||
[plugin.instanceId, onSetControl]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bus-strip">
|
||||
{/* Master section */}
|
||||
<div className="bus-master-section">
|
||||
<div className="bus-master-header">
|
||||
<span className="bus-master-label">Master</span>
|
||||
<button
|
||||
className={`bus-btn mute ${masterMute ? 'active' : ''}`}
|
||||
onClick={toggleMasterMute}
|
||||
title="Master Mute"
|
||||
>
|
||||
M
|
||||
</button>
|
||||
</div>
|
||||
<div className="bus-master-meter">
|
||||
<LevelMeter
|
||||
levelL={masterLevelL}
|
||||
levelR={masterLevelR}
|
||||
mini={true}
|
||||
label={masterVol > -60 ? `${masterVol.toFixed(1)}dB` : '-∞'}
|
||||
/>
|
||||
</div>
|
||||
<div className="bus-master-fader">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.5"
|
||||
value={dbToSliderPos(masterVol)}
|
||||
onChange={handleMasterVol}
|
||||
className="bus-fader"
|
||||
title={`Master Volume: ${masterVol.toFixed(1)} dB`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="bus-divider" />
|
||||
|
||||
{/* Per-channel mini strips */}
|
||||
<div className="bus-channels">
|
||||
{channels.map((ch) => (
|
||||
<div key={ch.index} className="bus-channel">
|
||||
<span className="bus-channel-label">
|
||||
{ch.index}
|
||||
</span>
|
||||
<button
|
||||
className={`bus-btn mute mini ${ch.mute ? 'active' : ''}`}
|
||||
onClick={() => toggleChannelMute(ch.index, ch.muteKey, ch.mute)}
|
||||
title={`Ch ${ch.index} Mute`}
|
||||
>
|
||||
M
|
||||
</button>
|
||||
<div className="bus-channel-meter">
|
||||
<LevelMeter
|
||||
levelL={ch.levelL}
|
||||
levelR={ch.levelR}
|
||||
mini={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="bus-channel-fader">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.5"
|
||||
value={dbToSliderPos(ch.vol)}
|
||||
onChange={(e) => handleChannelVol(ch.index, ch.volKey, e)}
|
||||
className="bus-fader mini"
|
||||
title={`Ch ${ch.index} Volume: ${ch.vol.toFixed(1)} dB`}
|
||||
/>
|
||||
</div>
|
||||
<span className="bus-channel-db">
|
||||
{ch.vol > -60 ? ch.vol.toFixed(1) : '-∞'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
.channel-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 8px;
|
||||
background: var(--surface, #16213e);
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
border-top: 3px solid #666;
|
||||
border-radius: var(--radius, 8px);
|
||||
min-width: 80px;
|
||||
max-width: 100px;
|
||||
transition: border-top-color 0.3s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.channel-strip:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ---- Header ---- */
|
||||
|
||||
.channel-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channel-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
color: var(--text, #eaeaea);
|
||||
}
|
||||
|
||||
/* ---- Mute / Solo ---- */
|
||||
|
||||
.channel-mute-solo {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.channel-btn {
|
||||
width: 28px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: var(--bg, #1a1a2e);
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.channel-btn.mute.active {
|
||||
background: var(--error, #f44336);
|
||||
color: #fff;
|
||||
border-color: var(--error, #f44336);
|
||||
}
|
||||
|
||||
.channel-btn.solo.active {
|
||||
background: var(--warning, #ff9800);
|
||||
color: #000;
|
||||
border-color: var(--warning, #ff9800);
|
||||
}
|
||||
|
||||
.channel-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ---- Level Meter Section ---- */
|
||||
|
||||
.channel-meter-section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* ---- Pan ---- */
|
||||
|
||||
.channel-pan {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.channel-pan-label {
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.channel-pan-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channel-pan-slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
background: var(--border, #2a2a4a);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-pan-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--channel-accent, #666);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-pan-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--channel-accent, #666);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-pan-value {
|
||||
font-size: 0.55rem;
|
||||
font-family: monospace;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- Fader ---- */
|
||||
|
||||
.channel-fader-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channel-fader {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
background: linear-gradient(to top, var(--bg, #1a1a2e) 0%, var(--surface-alt, #0f3460) 75%, #4caf50 85%, #ff9800 95%, #f44336 100%);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
/* WebKit vertical slider */
|
||||
.channel-fader::-webkit-slider-runnable-track {
|
||||
height: 100px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.channel-fader::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
background: var(--channel-accent, #666);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
.channel-fader::-moz-range-track {
|
||||
height: 100px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.channel-fader::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
background: var(--channel-accent, #666);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.channel-fader-readout {
|
||||
font-size: 0.6rem;
|
||||
font-family: monospace;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- Instrument Selector ---- */
|
||||
|
||||
.channel-instrument-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channel-instrument-select {
|
||||
width: 100%;
|
||||
padding: 3px 4px;
|
||||
background: var(--bg, #1a1a2e);
|
||||
color: var(--text, #eaeaea);
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
border-radius: 3px;
|
||||
font-size: 0.6rem;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.channel-instrument-select:focus {
|
||||
border-color: var(--channel-accent, #666);
|
||||
}
|
||||
|
||||
.channel-instrument-select option {
|
||||
background: var(--bg, #1a1a2e);
|
||||
color: var(--text, #eaeaea);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import type { PluginInstance } from '../../hooks/usePiPedalWS';
|
||||
import type { Instrument } from './InstrumentBadge';
|
||||
import { InstrumentBadge, INSTRUMENT_NAMES, INSTRUMENT_COLORS } from './InstrumentBadge';
|
||||
import { LevelMeter } from './LevelMeter';
|
||||
import './ChannelStrip.css';
|
||||
|
||||
interface Props {
|
||||
plugin: PluginInstance;
|
||||
/** Callback to set a control value on the plugin */
|
||||
onSetControl: (instanceId: number, key: string, value: number) => void;
|
||||
}
|
||||
|
||||
const INSTRUMENT_VALUES: Instrument[] = [0, 1, 2, 3, 4];
|
||||
|
||||
/**
|
||||
* Map a dB slider position (0-1) to a dB value (-60..+6).
|
||||
* 75% position = 0 dB (unity).
|
||||
*/
|
||||
function posToDb(pos: number): number {
|
||||
if (pos <= 0) return -60;
|
||||
if (pos >= 1) return 6;
|
||||
// Logarithmic taper: 0.75 = 0 dB
|
||||
const scaled = pos / 0.75;
|
||||
if (scaled <= 0.001) return -60;
|
||||
const db = 20 * Math.log10(scaled);
|
||||
return Math.max(-60, Math.min(6, db));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a dB value (-60..+6) to a slider position (0-1).
|
||||
*/
|
||||
function dbToPos(db: number): number {
|
||||
if (db <= -60) return 0;
|
||||
if (db >= 6) return 1;
|
||||
// Inverse of posToDb
|
||||
const linear = Math.pow(10, db / 20);
|
||||
return Math.min(1, linear * 0.75);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full channel strip with fader, pan, mute/solo, instrument selector, level meter.
|
||||
*/
|
||||
export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
|
||||
const cv = plugin.controlValues;
|
||||
|
||||
const volume = cv.volume ?? 0;
|
||||
const pan = cv.pan ?? 0;
|
||||
const mute = (cv.mute ?? 0) >= 0.5;
|
||||
const solo = (cv.solo ?? 0) >= 0.5;
|
||||
const instrument = (Math.round(cv.instrument ?? 0) || 0) as Instrument;
|
||||
const levelL = cv.levelL ?? -60;
|
||||
const levelR = cv.levelR ?? -60;
|
||||
|
||||
const instrumentColor = INSTRUMENT_COLORS[instrument] ?? '#666';
|
||||
|
||||
const handleVolumeChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const pos = parseFloat(e.target.value) / 100;
|
||||
const db = posToDb(pos);
|
||||
onSetControl(plugin.instanceId, 'volume', parseFloat(db.toFixed(1)));
|
||||
},
|
||||
[plugin.instanceId, onSetControl]
|
||||
);
|
||||
|
||||
const handlePanChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
onSetControl(plugin.instanceId, 'pan', val);
|
||||
},
|
||||
[plugin.instanceId, onSetControl]
|
||||
);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
onSetControl(plugin.instanceId, 'mute', mute ? 0 : 1);
|
||||
}, [plugin.instanceId, mute, onSetControl]);
|
||||
|
||||
const toggleSolo = useCallback(() => {
|
||||
onSetControl(plugin.instanceId, 'solo', solo ? 0 : 1);
|
||||
}, [plugin.instanceId, solo, onSetControl]);
|
||||
|
||||
const handleInstrumentChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
onSetControl(plugin.instanceId, 'instrument', val);
|
||||
},
|
||||
[plugin.instanceId, onSetControl]
|
||||
);
|
||||
|
||||
const volumeSliderPos = dbToPos(volume) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="channel-strip"
|
||||
style={{
|
||||
borderTopColor: instrumentColor,
|
||||
'--channel-accent': instrumentColor,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{/* Header: channel label + instrument */}
|
||||
<div className="channel-header">
|
||||
<span className="channel-label" title={plugin.title || plugin.pluginName}>
|
||||
{plugin.title || plugin.pluginName || `Ch ${plugin.instanceId}`}
|
||||
</span>
|
||||
<InstrumentBadge instrument={instrument} size="small" />
|
||||
</div>
|
||||
|
||||
{/* Mute & Solo buttons */}
|
||||
<div className="channel-mute-solo">
|
||||
<button
|
||||
className={`channel-btn mute ${mute ? 'active' : ''}`}
|
||||
onClick={toggleMute}
|
||||
title="Mute"
|
||||
>
|
||||
M
|
||||
</button>
|
||||
<button
|
||||
className={`channel-btn solo ${solo ? 'active' : ''}`}
|
||||
onClick={toggleSolo}
|
||||
title="Solo"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Level Meter */}
|
||||
<div className="channel-meter-section">
|
||||
<LevelMeter levelL={levelL} levelR={levelR} mini={false} />
|
||||
</div>
|
||||
|
||||
{/* Pan control */}
|
||||
<div className="channel-pan">
|
||||
<label className="channel-pan-label">Pan</label>
|
||||
<div className="channel-pan-control">
|
||||
<input
|
||||
type="range"
|
||||
min="-1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={pan}
|
||||
onChange={handlePanChange}
|
||||
className="channel-pan-slider"
|
||||
title={`Pan: ${pan.toFixed(2)}`}
|
||||
/>
|
||||
<span className="channel-pan-value">
|
||||
{pan === 0 ? 'C' : pan < 0 ? `L${Math.abs(Math.round(pan * 100))}` : `R${Math.round(pan * 100)}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume fader */}
|
||||
<div className="channel-fader-section">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.5"
|
||||
value={volumeSliderPos}
|
||||
onChange={handleVolumeChange}
|
||||
className="channel-fader"
|
||||
title={`Volume: ${volume.toFixed(1)} dB`}
|
||||
{...({ orient: 'vertical' } as React.InputHTMLAttributes<HTMLInputElement>)}
|
||||
/>
|
||||
<span className="channel-fader-readout">
|
||||
{volume > -60 ? `${volume >= 0 ? '+' : ''}${volume.toFixed(1)}` : '-∞'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Instrument selector */}
|
||||
<div className="channel-instrument-selector">
|
||||
<select
|
||||
value={instrument}
|
||||
onChange={handleInstrumentChange}
|
||||
className="channel-instrument-select"
|
||||
style={{ borderColor: instrumentColor }}
|
||||
>
|
||||
{INSTRUMENT_VALUES.map((val) => (
|
||||
<option key={val} value={val}>
|
||||
{INSTRUMENT_NAMES[val]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
.instrument-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.instrument-badge:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.instrument-badge[data-size='small'] {
|
||||
font-size: 0.6rem;
|
||||
padding: 1px 5px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.instrument-badge[data-size='large'] {
|
||||
font-size: 0.85rem;
|
||||
padding: 4px 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.instrument-icon {
|
||||
font-size: 0.8em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.instrument-name {
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import './InstrumentBadge.css';
|
||||
|
||||
export type Instrument = 0 | 1 | 2 | 3 | 4;
|
||||
|
||||
export const INSTRUMENT_NAMES: Record<Instrument, string> = {
|
||||
0: 'Guitar',
|
||||
1: 'Bass',
|
||||
2: 'Keys',
|
||||
3: 'Vocals',
|
||||
4: 'Backing',
|
||||
};
|
||||
|
||||
export const INSTRUMENT_COLORS: Record<Instrument, string> = {
|
||||
0: '#ff8c00', // Guitar: orange
|
||||
1: '#2196f3', // Bass: blue
|
||||
2: '#9c27b0', // Keys: purple
|
||||
3: '#e91e63', // Vocals: pink
|
||||
4: '#009688', // Backing: teal
|
||||
};
|
||||
|
||||
export const INSTRUMENT_ICONS: Record<Instrument, string> = {
|
||||
0: '🎸',
|
||||
1: '🎸',
|
||||
2: '🎹',
|
||||
3: '🎤',
|
||||
4: '🎵',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
instrument: Instrument;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export const InstrumentBadge: React.FC<Props> = ({
|
||||
instrument,
|
||||
size = 'medium',
|
||||
showIcon = true,
|
||||
}) => {
|
||||
const name = INSTRUMENT_NAMES[instrument] ?? `Unknown (${instrument})`;
|
||||
const color = INSTRUMENT_COLORS[instrument] ?? '#666';
|
||||
const icon = INSTRUMENT_ICONS[instrument] ?? '';
|
||||
|
||||
return (
|
||||
<span
|
||||
className="instrument-badge"
|
||||
data-size={size}
|
||||
style={{
|
||||
backgroundColor: `${color}22`,
|
||||
color: color,
|
||||
borderColor: color,
|
||||
}}
|
||||
title={name}
|
||||
>
|
||||
{showIcon && <span className="instrument-icon">{icon}</span>}
|
||||
<span className="instrument-name">{name}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
.level-meter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
--meter-green: #4caf50;
|
||||
--meter-yellow: #ff9800;
|
||||
--meter-red: #f44336;
|
||||
}
|
||||
|
||||
.level-meter.mini {
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.level-meter-stereo-pair {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.level-meter-channel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.level-meter-channel.mini {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.level-meter-side-label {
|
||||
font-size: 0.55rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.level-meter-bar-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.level-meter.mini .level-meter-bar-track {
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.level-meter-bar-fill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transition: height 0.05s linear;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.level-meter-peak {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #fff;
|
||||
border-radius: 1px;
|
||||
transition: bottom 0.15s ease-out;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.level-meter-tick {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.level-meter-tick.mark-yellow {
|
||||
background: rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
.level-meter-tick.mark-green {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.level-meter-label {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.level-meter-db-values {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
font-size: 0.6rem;
|
||||
font-family: monospace;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.level-meter-db-values .hot {
|
||||
color: var(--meter-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import './LevelMeter.css';
|
||||
|
||||
interface Props {
|
||||
/** Left channel level in dB (-60..0) */
|
||||
levelL: number;
|
||||
/** Right channel level in dB (-60..0) */
|
||||
levelR: number;
|
||||
/** Optional label below the meter */
|
||||
label?: string;
|
||||
/** Whether to show as a compact mini meter (for bus strip) */
|
||||
mini?: boolean;
|
||||
/** Peak hold duration in ms (default: 1000) */
|
||||
peakHold?: number;
|
||||
}
|
||||
|
||||
const DB_MIN = -60;
|
||||
const DB_MAX = 0;
|
||||
|
||||
/**
|
||||
* Converts a dB value to a fill percentage (0-100).
|
||||
* Uses a perceptual curve so -20dB appears around 50%.
|
||||
*/
|
||||
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); // 0..1
|
||||
// Apply a curve: sqrt for VU-like response
|
||||
return Math.sqrt(normalized) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable stereo level meter with LED-style bars and peak hold dots.
|
||||
*/
|
||||
export const LevelMeter: React.FC<Props> = ({
|
||||
levelL,
|
||||
levelR,
|
||||
label,
|
||||
mini = false,
|
||||
peakHold = 1500,
|
||||
}) => {
|
||||
const peakLRef = useRef<number>(DB_MIN);
|
||||
const peakRRef = useRef<number>(DB_MIN);
|
||||
const peakTimerL = useRef<number | null>(null);
|
||||
const peakTimerR = useRef<number | null>(null);
|
||||
const [, forceUpdate] = React.useState(0);
|
||||
|
||||
// Update peak holds
|
||||
useEffect(() => {
|
||||
if (levelL > peakLRef.current) {
|
||||
peakLRef.current = levelL;
|
||||
if (peakTimerL.current) clearTimeout(peakTimerL.current);
|
||||
peakTimerL.current = window.setTimeout(() => {
|
||||
peakLRef.current = Math.max(levelL, DB_MIN);
|
||||
forceUpdate((n) => n + 1);
|
||||
}, peakHold);
|
||||
}
|
||||
if (levelR > peakRRef.current) {
|
||||
peakRRef.current = levelR;
|
||||
if (peakTimerR.current) clearTimeout(peakTimerR.current);
|
||||
peakTimerR.current = window.setTimeout(() => {
|
||||
peakRRef.current = Math.max(levelR, DB_MIN);
|
||||
forceUpdate((n) => n + 1);
|
||||
}, peakHold);
|
||||
}
|
||||
}, [levelL, levelR, peakHold]);
|
||||
|
||||
const pctL = dbToPercent(levelL);
|
||||
const pctR = dbToPercent(levelR);
|
||||
const peakPctL = dbToPercent(peakLRef.current);
|
||||
const peakPctR = dbToPercent(peakRRef.current);
|
||||
|
||||
const getColor = (pct: number): string => {
|
||||
// Green < 60%, Yellow 60-85%, Red > 85%
|
||||
// (60% ≈ -20dB, 85% ≈ -6dB with sqrt curve)
|
||||
if (pct < 60) return 'var(--meter-green, #4caf50)';
|
||||
if (pct < 85) return 'var(--meter-yellow, #ff9800)';
|
||||
return 'var(--meter-red, #f44336)';
|
||||
};
|
||||
|
||||
const renderMeter = (pct: number, peakPct: number, side: 'L' | 'R') => (
|
||||
<div className={`level-meter-channel ${mini ? 'mini' : ''}`}>
|
||||
{!mini && <span className="level-meter-side-label">{side}</span>}
|
||||
<div className="level-meter-bar-track">
|
||||
<div
|
||||
className="level-meter-bar-fill"
|
||||
style={{
|
||||
height: `${Math.min(100, pct)}%`,
|
||||
backgroundColor: getColor(pct),
|
||||
}}
|
||||
/>
|
||||
{/* Peak hold dot */}
|
||||
<div
|
||||
className="level-meter-peak"
|
||||
style={{
|
||||
bottom: `${Math.min(100, peakPct)}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Tick marks at -6, -20, -40 dB */}
|
||||
{!mini && (
|
||||
<>
|
||||
<div className="level-meter-tick" style={{ bottom: `${dbToPercent(-6)}%` }} />
|
||||
<div className="level-meter-tick mark-yellow" style={{ bottom: `${dbToPercent(-20)}%` }} />
|
||||
<div className="level-meter-tick mark-green" style={{ bottom: `${dbToPercent(-40)}%` }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`level-meter ${mini ? 'mini' : ''}`}>
|
||||
<div className="level-meter-stereo-pair">
|
||||
{renderMeter(pctL, peakPctL, 'L')}
|
||||
{renderMeter(pctR, peakPctR, 'R')}
|
||||
</div>
|
||||
{label && <div className="level-meter-label">{label}</div>}
|
||||
{!mini && (
|
||||
<div className="level-meter-db-values">
|
||||
<span className={levelL > -10 ? 'hot' : ''}>{levelL.toFixed(1)}</span>
|
||||
<span className={levelR > -10 ? 'hot' : ''}>{levelR.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
.mixer-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--bg, #1a1a2e);
|
||||
/* Override the app-main max-width for mixer */
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* ---- Header ---- */
|
||||
|
||||
.mixer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: var(--surface, #16213e);
|
||||
border-bottom: 1px solid var(--border, #2a2a4a);
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.mixer-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mixer-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text, #eaeaea);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.mixer-header-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mixer-pedalboard-name {
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent, #e94560);
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius, 8px);
|
||||
}
|
||||
|
||||
.mixer-ws-indicator {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mixer-ws-indicator.ready {
|
||||
color: var(--success, #4caf50);
|
||||
background: rgba(76, 175, 80, 0.12);
|
||||
}
|
||||
|
||||
.mixer-ws-indicator.pending {
|
||||
color: var(--warning, #ff9800);
|
||||
background: rgba(255, 152, 0, 0.12);
|
||||
}
|
||||
|
||||
.mixer-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mixer-update-time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
}
|
||||
|
||||
.mixer-refresh-btn {
|
||||
background: var(--accent, #e94560);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius, 8px);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.mixer-refresh-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mixer-refresh-btn.large {
|
||||
padding: 10px 24px;
|
||||
font-size: 0.95rem;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* ---- Scenes Button ---- */
|
||||
|
||||
.mixer-scenes-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--surface-alt, #0f3460);
|
||||
color: var(--text, #eaeaea);
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius, 8px);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mixer-scenes-btn:hover {
|
||||
border-color: var(--accent, #e94560);
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
}
|
||||
|
||||
.mixer-scenes-btn.active {
|
||||
border-color: var(--accent, #e94560);
|
||||
background: rgba(233, 69, 96, 0.15);
|
||||
color: var(--accent, #e94560);
|
||||
}
|
||||
|
||||
.mixer-scenes-count {
|
||||
font-size: 0.65rem;
|
||||
background: var(--accent, #e94560);
|
||||
color: #fff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Content ---- */
|
||||
|
||||
.mixer-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* ---- Section Headers ---- */
|
||||
|
||||
.mixer-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mixer-section-header h2 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eaeaea);
|
||||
}
|
||||
|
||||
.mixer-count {
|
||||
font-size: 0.7rem;
|
||||
background: var(--surface-alt, #0f3460);
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ---- Channels Grid ---- */
|
||||
|
||||
.mixer-channels-grid {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ---- Add Channel ---- */
|
||||
|
||||
.mixer-add-channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mixer-add-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 2px dashed var(--border, #2a2a4a);
|
||||
background: transparent;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mixer-add-btn:hover {
|
||||
border-color: var(--accent, #e94560);
|
||||
color: var(--accent, #e94560);
|
||||
background: rgba(233, 69, 96, 0.08);
|
||||
}
|
||||
|
||||
/* ---- Buses Grid ---- */
|
||||
|
||||
.mixer-buses-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ---- Empty State ---- */
|
||||
|
||||
.mixer-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mixer-empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mixer-empty h2 {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text, #eaeaea);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mixer-empty p {
|
||||
font-size: 0.9rem;
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mixer-empty-hint {
|
||||
font-size: 0.8rem !important;
|
||||
opacity: 0.7;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import type { PluginInstance } from '../../hooks/usePiPedalWS';
|
||||
import type { SceneState } from '../../hooks/useScenes';
|
||||
import { useMixerControls } from '../../hooks/useMixerControls';
|
||||
import { useScenes } from '../../hooks/useScenes';
|
||||
import { ChannelStrip } from './ChannelStrip';
|
||||
import { BusStrip } from './BusStrip';
|
||||
import { SceneManager } from '../Scenes/SceneManager';
|
||||
import { ConnectionStatus } from '../ConnectionStatus';
|
||||
import type { ConnectionState } from '../../hooks/usePiPedalWS';
|
||||
import './MixerPage.css';
|
||||
import './ChannelStrip.css';
|
||||
import './BusStrip.css';
|
||||
import './LevelMeter.css';
|
||||
import './InstrumentBadge.css';
|
||||
|
||||
interface Props {
|
||||
plugins: PluginInstance[];
|
||||
connectionState: ConnectionState;
|
||||
pedalboardName: string | null;
|
||||
lastUpdate: number;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main mixer page layout.
|
||||
* - Header with connection status and scene selector
|
||||
* - Sidebar of channel strips (left)
|
||||
* - Bus meter bridge (right)
|
||||
* - Scene management sidebar
|
||||
*/
|
||||
export const MixerPage: React.FC<Props> = ({
|
||||
plugins,
|
||||
connectionState,
|
||||
pedalboardName,
|
||||
lastUpdate,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const { ready, setControlValue } = useMixerControls();
|
||||
const {
|
||||
scenes,
|
||||
loading: scenesLoading,
|
||||
error: scenesError,
|
||||
saveScene,
|
||||
deleteScene,
|
||||
recallScene,
|
||||
} = useScenes();
|
||||
|
||||
const [scenePanelOpen, setScenePanelOpen] = useState(false);
|
||||
|
||||
// Separate plugins by type
|
||||
const channels = useMemo(
|
||||
() =>
|
||||
plugins.filter(
|
||||
(p) =>
|
||||
!p.uri.includes('band-bus') && p.uri.includes('band-channel')
|
||||
),
|
||||
[plugins]
|
||||
);
|
||||
|
||||
const buses = useMemo(
|
||||
() => plugins.filter((p) => p.uri.includes('band-bus')),
|
||||
[plugins]
|
||||
);
|
||||
|
||||
const hasChannels = channels.length > 0;
|
||||
const hasBuses = buses.length > 0;
|
||||
const hasAny = plugins.length > 0;
|
||||
|
||||
/**
|
||||
* Capture current plugin state for saving as a scene.
|
||||
* Returns channels and buses with their instanceIds and current controlValues.
|
||||
*/
|
||||
const getCurrentState = useCallback((): SceneState | null => {
|
||||
if (plugins.length === 0) return null;
|
||||
|
||||
return {
|
||||
channels: channels.map((ch) => ({
|
||||
instanceId: ch.instanceId,
|
||||
params: { ...ch.controlValues },
|
||||
})),
|
||||
buses: buses.map((bus) => ({
|
||||
instanceId: bus.instanceId,
|
||||
params: { ...bus.controlValues },
|
||||
})),
|
||||
};
|
||||
}, [plugins, channels, buses]);
|
||||
|
||||
const toggleScenePanel = useCallback(() => {
|
||||
setScenePanelOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mixer-page">
|
||||
{/* Header */}
|
||||
<header className="mixer-header">
|
||||
<div className="mixer-header-left">
|
||||
<h1 className="mixer-title">OPLabs Mixer</h1>
|
||||
<ConnectionStatus state={connectionState} onRetry={onRefresh} />
|
||||
</div>
|
||||
<div className="mixer-header-center">
|
||||
{pedalboardName && (
|
||||
<span className="mixer-pedalboard-name">
|
||||
Pedalboard: {pedalboardName}
|
||||
</span>
|
||||
)}
|
||||
{connectionState.status === 'connected' && (
|
||||
<span className={`mixer-ws-indicator ${ready ? 'ready' : 'pending'}`}>
|
||||
{ready ? 'Controls Ready' : 'Connecting Controls...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mixer-header-right">
|
||||
<span className="mixer-update-time">
|
||||
{lastUpdate > 0
|
||||
? `Updated ${new Date(lastUpdate).toLocaleTimeString()}`
|
||||
: ''}
|
||||
</span>
|
||||
<button
|
||||
className={`mixer-scenes-btn ${scenePanelOpen ? 'active' : ''}`}
|
||||
onClick={toggleScenePanel}
|
||||
title="Scene Manager"
|
||||
>
|
||||
🎬 Scenes
|
||||
{scenes.length > 0 && (
|
||||
<span className="mixer-scenes-count">{scenes.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button className="mixer-refresh-btn" onClick={onRefresh}>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="mixer-content">
|
||||
{/* Empty state */}
|
||||
{!hasAny && (
|
||||
<div className="mixer-empty">
|
||||
<div className="mixer-empty-icon">🎛️</div>
|
||||
<h2>No OPLabs Plugins Found</h2>
|
||||
<p>
|
||||
Load OPLabsBandChannel or OPLabsBandBus plugins on the PiPedal
|
||||
pedalboard, then click Refresh.
|
||||
</p>
|
||||
{connectionState.status !== 'connected' && (
|
||||
<p className="mixer-empty-hint">
|
||||
Status: <strong>{connectionState.status}</strong>
|
||||
{connectionState.error && ` — ${connectionState.error}`}
|
||||
</p>
|
||||
)}
|
||||
<button className="mixer-refresh-btn large" onClick={onRefresh}>
|
||||
↻ Refresh Now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel strips */}
|
||||
{hasChannels && (
|
||||
<div className="mixer-channels">
|
||||
<div className="mixer-section-header">
|
||||
<h2>Channels</h2>
|
||||
<span className="mixer-count">{channels.length}</span>
|
||||
</div>
|
||||
<div className="mixer-channels-grid">
|
||||
{channels.map((plugin) => (
|
||||
<ChannelStrip
|
||||
key={plugin.instanceId}
|
||||
plugin={plugin}
|
||||
onSetControl={setControlValue}
|
||||
/>
|
||||
))}
|
||||
{/* Add channel button */}
|
||||
<div className="mixer-add-channel">
|
||||
<button className="mixer-add-btn" title="Add channel (future)">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bus strips */}
|
||||
{hasBuses && (
|
||||
<div className="mixer-buses">
|
||||
<div className="mixer-section-header">
|
||||
<h2>Bus</h2>
|
||||
<span className="mixer-count">{buses.length}</span>
|
||||
</div>
|
||||
<div className="mixer-buses-grid">
|
||||
{buses.map((plugin) => (
|
||||
<BusStrip
|
||||
key={plugin.instanceId}
|
||||
plugin={plugin}
|
||||
onSetControl={setControlValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scene Manager sidebar */}
|
||||
{scenePanelOpen && (
|
||||
<SceneManager
|
||||
scenes={scenes}
|
||||
loading={scenesLoading}
|
||||
error={scenesError}
|
||||
onClose={() => setScenePanelOpen(false)}
|
||||
onSaveScene={saveScene}
|
||||
onDeleteScene={deleteScene}
|
||||
onRecallScene={recallScene}
|
||||
getCurrentState={getCurrentState}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import type { PluginInstance } from '../hooks/usePiPedalWS';
|
||||
|
||||
interface Props {
|
||||
plugins: PluginInstance[];
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
// Port metadata for our OPLabs plugins
|
||||
const PORT_META: Record<string, Record<string, { label: string; unit?: string }>> = {
|
||||
'http://ourpad.casa/plugins/oplabs-band-channel': {
|
||||
volume: { label: 'Volume', unit: 'dB' },
|
||||
pan: { label: 'Pan' },
|
||||
mute: { label: 'Mute' },
|
||||
solo: { label: 'Solo' },
|
||||
instrument: { label: 'Instrument' },
|
||||
levelL: { label: 'Level L', unit: 'dB' },
|
||||
levelR: { label: 'Level R', unit: 'dB' },
|
||||
},
|
||||
};
|
||||
|
||||
const BUS_PORTS: Record<string, { label: string; unit?: string }> = {
|
||||
masterVol: { label: 'Master Vol', unit: 'dB' },
|
||||
masterMute: { label: 'Master Mute' },
|
||||
};
|
||||
for (let ch = 1; ch <= 8; ch++) {
|
||||
BUS_PORTS[`ch${ch}Vol`] = { label: `Ch ${ch} Vol`, unit: 'dB' };
|
||||
BUS_PORTS[`ch${ch}Pan`] = { label: `Ch ${ch} Pan` };
|
||||
BUS_PORTS[`ch${ch}Mute`] = { label: `Ch ${ch} Mute` };
|
||||
}
|
||||
|
||||
function getPortLabel(uri: string, symbol: string): string {
|
||||
const meta = PORT_META[uri];
|
||||
if (meta && meta[symbol]) return meta[symbol].label;
|
||||
if (BUS_PORTS[symbol]) return BUS_PORTS[symbol].label;
|
||||
return symbol;
|
||||
}
|
||||
|
||||
function formatValue(uri: string, symbol: string, value: number): string {
|
||||
const meta = PORT_META[uri];
|
||||
const info = meta?.[symbol] || BUS_PORTS[symbol];
|
||||
if (symbol.endsWith('Mute') || symbol === 'mute' || symbol === 'solo') {
|
||||
return value >= 0.5 ? 'ON' : 'off';
|
||||
}
|
||||
if (symbol === 'instrument') {
|
||||
const names = ['Guitar', 'Bass', 'Keys', 'Vocals', 'Backing'];
|
||||
const idx = Math.round(value);
|
||||
return names[idx] || String(value);
|
||||
}
|
||||
if (info?.unit === 'dB') {
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(1)} dB`;
|
||||
}
|
||||
return value.toFixed(3);
|
||||
}
|
||||
|
||||
export const PluginList: React.FC<Props> = ({ plugins, lastUpdate }) => {
|
||||
if (plugins.length === 0) {
|
||||
return (
|
||||
<div className="plugin-list empty">
|
||||
<p>No OPLabs plugin instances found in the current pedalboard.</p>
|
||||
<p className="hint">
|
||||
Load OPLabsBandChannel or OPLabsBandBus plugins on the PiPedal pedalboard,
|
||||
then refresh.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="plugin-list">
|
||||
<div className="plugin-list-header">
|
||||
<h2>Discovered OPLabs Plugins</h2>
|
||||
<span className="plugin-count">{plugins.length} instance{plugins.length !== 1 ? 's' : ''}</span>
|
||||
<span className="update-time">
|
||||
Last updated: {new Date(lastUpdate).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{plugins.map((plugin) => {
|
||||
const portSymbols = Object.keys(plugin.controlValues);
|
||||
const typeLabel = plugin.uri.includes('band-bus') ? 'Band Bus' : 'Band Channel';
|
||||
|
||||
return (
|
||||
<div key={plugin.instanceId} className="plugin-card">
|
||||
<div className="plugin-card-header">
|
||||
<span className={`plugin-type-badge ${plugin.uri.includes('band-bus') ? 'bus' : 'channel'}`}>
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="plugin-name">{plugin.title || plugin.pluginName || 'Unnamed'}</span>
|
||||
<span className="plugin-instance-id">ID: {plugin.instanceId}</span>
|
||||
<span className={`plugin-enabled ${plugin.isEnabled ? 'enabled' : 'disabled'}`}>
|
||||
{plugin.isEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="plugin-uri">{plugin.uri}</div>
|
||||
|
||||
<div className="plugin-ports">
|
||||
{portSymbols.map((symbol) => (
|
||||
<div key={symbol} className="port-row">
|
||||
<span className="port-label">{getPortLabel(plugin.uri, symbol)}</span>
|
||||
<span className="port-symbol">{symbol}</span>
|
||||
<span className="port-value">
|
||||
{formatValue(plugin.uri, symbol, plugin.controlValues[symbol])}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,352 @@
|
||||
.scene-manager-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 360px;
|
||||
height: 100vh;
|
||||
background: var(--surface, #16213e);
|
||||
border-left: 1px solid var(--border, #2a2a4a);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4);
|
||||
animation: scene-slide-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes scene-slide-in {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.scene-manager-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border, #2a2a4a);
|
||||
}
|
||||
|
||||
.scene-manager-header h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eaeaea);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scene-manager-count {
|
||||
font-size: 0.7rem;
|
||||
background: var(--surface-alt, #0f3460);
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.scene-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.scene-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text, #eaeaea);
|
||||
}
|
||||
|
||||
/* ---- Scene List ---- */
|
||||
|
||||
.scene-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.scene-list-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
font-size: 0.85rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scene-list-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---- Scene Card ---- */
|
||||
|
||||
.scene-card {
|
||||
background: var(--bg, #1a1a2e);
|
||||
border-radius: var(--radius, 8px);
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.scene-card:hover {
|
||||
border-color: var(--accent, #e94560);
|
||||
}
|
||||
|
||||
.scene-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scene-card-content:hover {
|
||||
background: rgba(233, 69, 96, 0.04);
|
||||
}
|
||||
|
||||
.scene-card-name {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text, #eaeaea);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.scene-card-midi {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
background: var(--surface-alt, #0f3460);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scene-card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.scene-card-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.scene-card-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text, #eaeaea);
|
||||
}
|
||||
|
||||
.scene-card-btn.delete:hover {
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
color: var(--error, #f44336);
|
||||
}
|
||||
|
||||
.scene-card-btn.load:hover {
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
color: var(--success, #4caf50);
|
||||
}
|
||||
|
||||
.scene-card-btn.recall {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scene-card-btn.recall:hover {
|
||||
background: rgba(233, 69, 96, 0.15);
|
||||
color: var(--accent, #e94560);
|
||||
}
|
||||
|
||||
/* ---- Save New Scene Form ---- */
|
||||
|
||||
.scene-save-form {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border, #2a2a4a);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scene-save-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scene-name-input {
|
||||
flex: 1;
|
||||
background: var(--bg, #1a1a2e);
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
color: var(--text, #eaeaea);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.scene-name-input:focus {
|
||||
border-color: var(--accent, #e94560);
|
||||
}
|
||||
|
||||
.scene-name-input::placeholder {
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
}
|
||||
|
||||
.scene-save-btn {
|
||||
background: var(--accent, #e94560);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.scene-save-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.scene-save-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.scene-midi-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scene-midi-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scene-midi-input {
|
||||
width: 48px;
|
||||
background: var(--bg, #1a1a2e);
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
color: var(--text, #eaeaea);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.scene-midi-input:focus {
|
||||
border-color: var(--accent, #e94560);
|
||||
}
|
||||
|
||||
/* ---- Error display ---- */
|
||||
|
||||
.scene-error {
|
||||
padding: 8px 16px;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: var(--error, #f44336);
|
||||
font-size: 0.75rem;
|
||||
border-top: 1px solid rgba(244, 67, 54, 0.2);
|
||||
}
|
||||
|
||||
/* ---- Confirmation Dialog ---- */
|
||||
|
||||
.scene-confirm-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.scene-confirm-dialog {
|
||||
background: var(--surface, #16213e);
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
border-radius: var(--radius, 8px);
|
||||
padding: 20px 24px;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.scene-confirm-dialog p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text, #eaeaea);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.scene-confirm-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.scene-confirm-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border, #2a2a4a);
|
||||
color: var(--text-dim, #9e9e9e);
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.scene-confirm-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.scene-confirm-delete {
|
||||
background: var(--error, #f44336);
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scene-confirm-delete:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ---- Recall feedback ---- */
|
||||
|
||||
.scene-recalling {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scene-recalled-flash {
|
||||
animation: scene-flash 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes scene-flash {
|
||||
0% { border-color: var(--success, #4caf50); box-shadow: 0 0 8px rgba(76, 175, 80, 0.3); }
|
||||
100% { border-color: var(--border, #2a2a4a); box-shadow: none; }
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import type { Scene, SceneState } from '../../hooks/useScenes';
|
||||
import './SceneManager.css';
|
||||
|
||||
interface Props {
|
||||
scenes: Scene[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSaveScene: (name: string, state: SceneState, midiPc?: number | null) => Promise<void>;
|
||||
onDeleteScene: (id: string) => Promise<void>;
|
||||
onRecallScene: (id: string) => Promise<boolean>;
|
||||
getCurrentState: () => SceneState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene Manager sidebar panel.
|
||||
* Lists saved scenes, allows saving new ones, loading/recalling, and deleting.
|
||||
*/
|
||||
export const SceneManager: React.FC<Props> = ({
|
||||
scenes,
|
||||
loading,
|
||||
error: externalError,
|
||||
onClose,
|
||||
onSaveScene,
|
||||
onDeleteScene,
|
||||
onRecallScene,
|
||||
getCurrentState,
|
||||
}) => {
|
||||
const [saveName, setSaveName] = useState('');
|
||||
const [midiPc, setMidiPc] = useState('');
|
||||
const [saveInProgress, setSaveInProgress] = useState(false);
|
||||
const [recallingId, setRecallingId] = useState<string | null>(null);
|
||||
const [recentlyRecalledId, setRecentlyRecalledId] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [internalError, setInternalError] = useState<string | null>(null);
|
||||
|
||||
const errorMsg = externalError || internalError;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmed = saveName.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const currentState = getCurrentState();
|
||||
if (!currentState) {
|
||||
setInternalError('No plugin state available. Refresh plugins first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveInProgress(true);
|
||||
setInternalError(null);
|
||||
try {
|
||||
const midiPcVal = midiPc.trim() !== '' ? parseInt(midiPc.trim(), 10) : null;
|
||||
await onSaveScene(trimmed, currentState, midiPcVal);
|
||||
setSaveName('');
|
||||
setMidiPc('');
|
||||
} catch {
|
||||
setInternalError('Failed to save scene');
|
||||
} finally {
|
||||
setSaveInProgress(false);
|
||||
}
|
||||
}, [saveName, midiPc, getCurrentState, onSaveScene]);
|
||||
|
||||
const handleRecall = useCallback(
|
||||
async (id: string) => {
|
||||
setRecallingId(id);
|
||||
setInternalError(null);
|
||||
try {
|
||||
const ok = await onRecallScene(id);
|
||||
if (ok) {
|
||||
setRecentlyRecalledId(id);
|
||||
setTimeout(() => setRecentlyRecalledId(null), 700);
|
||||
} else {
|
||||
setInternalError('Recall failed — check PiPedal connection');
|
||||
}
|
||||
} catch {
|
||||
setInternalError('Recall failed');
|
||||
} finally {
|
||||
setRecallingId(null);
|
||||
}
|
||||
},
|
||||
[onRecallScene]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
setInternalError(null);
|
||||
setDeleteConfirmId(null);
|
||||
try {
|
||||
await onDeleteScene(id);
|
||||
} catch {
|
||||
setInternalError('Failed to delete scene');
|
||||
}
|
||||
},
|
||||
[onDeleteScene]
|
||||
);
|
||||
|
||||
const handleSaveKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[handleSave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scene-manager-overlay">
|
||||
{/* Header */}
|
||||
<div className="scene-manager-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<h2>Scenes</h2>
|
||||
<span className="scene-manager-count">
|
||||
{scenes.length}
|
||||
</span>
|
||||
</div>
|
||||
<button className="scene-close-btn" onClick={onClose} title="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scene list */}
|
||||
<div className="scene-list">
|
||||
{loading && scenes.length === 0 && (
|
||||
<div className="scene-list-loading">Loading scenes...</div>
|
||||
)}
|
||||
|
||||
{!loading && scenes.length === 0 && (
|
||||
<div className="scene-list-empty">
|
||||
<span>No saved scenes yet</span>
|
||||
<span>Use the form below to save the current mixer state.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scenes.map((scene) => (
|
||||
<div
|
||||
key={scene.id}
|
||||
className={`scene-card ${
|
||||
recentlyRecalledId === scene.id ? 'scene-recalled-flash' : ''
|
||||
} ${recallingId === scene.id ? 'scene-recalling' : ''}`}
|
||||
>
|
||||
<div className="scene-card-content">
|
||||
<span
|
||||
className="scene-card-name"
|
||||
onClick={() => handleRecall(scene.id)}
|
||||
title={`Recall "${scene.name}"`}
|
||||
>
|
||||
{scene.name}
|
||||
</span>
|
||||
|
||||
{scene.midiPc !== null && scene.midiPc !== undefined && (
|
||||
<span className="scene-card-midi">PC#{scene.midiPc}</span>
|
||||
)}
|
||||
|
||||
<div className="scene-card-actions">
|
||||
<button
|
||||
className="scene-card-btn recall"
|
||||
onClick={() => handleRecall(scene.id)}
|
||||
disabled={recallingId === scene.id}
|
||||
title="Recall scene"
|
||||
>
|
||||
▶ Recall
|
||||
</button>
|
||||
<button
|
||||
className="scene-card-btn delete"
|
||||
onClick={() => setDeleteConfirmId(scene.id)}
|
||||
title="Delete scene"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Save new scene form */}
|
||||
<div className="scene-save-form">
|
||||
<div className="scene-save-row">
|
||||
<input
|
||||
className="scene-name-input"
|
||||
type="text"
|
||||
placeholder="Scene name..."
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
onKeyDown={handleSaveKeyDown}
|
||||
maxLength={60}
|
||||
/>
|
||||
<button
|
||||
className="scene-save-btn"
|
||||
onClick={handleSave}
|
||||
disabled={saveInProgress || !saveName.trim()}
|
||||
>
|
||||
{saveInProgress ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="scene-save-row">
|
||||
<div className="scene-midi-input-group">
|
||||
<span className="scene-midi-label">MIDI PC:</span>
|
||||
<input
|
||||
className="scene-midi-input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="127"
|
||||
placeholder="—"
|
||||
value={midiPc}
|
||||
onChange={(e) => setMidiPc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{errorMsg && (
|
||||
<div className="scene-error">{errorMsg}</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="scene-confirm-overlay" onClick={() => setDeleteConfirmId(null)}>
|
||||
<div className="scene-confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<p>Delete this scene permanently?</p>
|
||||
<div className="scene-confirm-actions">
|
||||
<button
|
||||
className="scene-confirm-cancel"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="scene-confirm-delete"
|
||||
onClick={() => handleDelete(deleteConfirmId)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook that provides a WebSocket connection for sending control values to PiPedal.
|
||||
* Maintains its own persistent WS connection (separate from usePiPedalWS discovery hook).
|
||||
*/
|
||||
export function useMixerControls() {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const reconnectTimer = useRef<number | null>(null);
|
||||
const isUnmounted = useRef(false);
|
||||
const replyHandlers = useRef<Map<number, (data: unknown) => void>>(new Map());
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/pipedal`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
setReady(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// Check if it's a reply to one of our pending requests
|
||||
if (Array.isArray(data) && data.length >= 1) {
|
||||
const hdr = data[0];
|
||||
if (hdr.reply !== undefined) {
|
||||
const handler = replyHandlers.current.get(hdr.reply);
|
||||
if (handler) {
|
||||
handler(data[1] || null);
|
||||
replyHandlers.current.delete(hdr.reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setReady(false);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setReady(false);
|
||||
replyHandlers.current.clear();
|
||||
if (!isUnmounted.current) {
|
||||
reconnectTimer.current = window.setTimeout(() => {
|
||||
if (!isUnmounted.current) connect();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
} catch {
|
||||
setReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
isUnmounted.current = false;
|
||||
connect();
|
||||
return () => {
|
||||
isUnmounted.current = true;
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
/**
|
||||
* Send a setControlValue message to PiPedal for a specific plugin instance parameter.
|
||||
*/
|
||||
const setControlValue = useCallback(
|
||||
(instanceId: number, key: string, value: number): void => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const replyTo = (Date.now() & 0x7fffffff) + Math.floor(Math.random() * 1000);
|
||||
const msg = JSON.stringify([
|
||||
{ message: 'setControlValue', replyTo },
|
||||
{ instanceId, key, value },
|
||||
]);
|
||||
|
||||
wsRef.current.send(msg);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { ready, setControlValue };
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
// PiPedal WebSocket protocol types
|
||||
export interface PiPedalMessageHeader {
|
||||
replyTo?: number;
|
||||
reply?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PluginInstance {
|
||||
instanceId: number;
|
||||
uri: string;
|
||||
pluginName: string;
|
||||
title: string;
|
||||
isEnabled: boolean;
|
||||
controlValues: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ConnectionState {
|
||||
status: 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DiscoverResponse {
|
||||
connected: boolean;
|
||||
oplabs_instances: PluginInstance[];
|
||||
pedalboard_name: string | null;
|
||||
}
|
||||
|
||||
// OPLabs plugin URIs we're looking for
|
||||
const OPLABS_URIS = new Set([
|
||||
'http://ourpad.casa/plugins/oplabs-band-channel',
|
||||
'http://ourpad.casa/plugins/oplabs-band-bus',
|
||||
'http://ourpad.casa/plugins/oplabs-band-bus#',
|
||||
]);
|
||||
|
||||
function isOplabsPlugin(uri: string): boolean {
|
||||
return OPLABS_URIS.has(uri);
|
||||
}
|
||||
|
||||
export interface UsePiPedalWSReturn {
|
||||
connectionState: ConnectionState;
|
||||
plugins: PluginInstance[];
|
||||
pedalboardName: string | null;
|
||||
discover: () => Promise<void>;
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that connects to the local WS proxy and discovers OPLabs plugin instances.
|
||||
*/
|
||||
export function usePiPedalWS(): UsePiPedalWSReturn {
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>({
|
||||
status: 'disconnected',
|
||||
});
|
||||
const [plugins, setPlugins] = useState<PluginInstance[]>([]);
|
||||
const [pedalboardName, setPedalboardName] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<number>(0);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<number | null>(null);
|
||||
const isUnmounted = useRef(false);
|
||||
|
||||
// Connect to the local WebSocket proxy
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/pipedal`;
|
||||
|
||||
setConnectionState({ status: 'connecting' });
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionState({ status: 'connected' });
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// Handle our custom messages
|
||||
if (data.type === 'connected') {
|
||||
setConnectionState({ status: 'connected' });
|
||||
} else if (data.type === 'error') {
|
||||
setConnectionState({ status: 'error', error: data.message });
|
||||
}
|
||||
// PiPedal protocol messages are relayed transparently
|
||||
} catch {
|
||||
// Binary or non-JSON data — ignore
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionState({ status: 'error', error: 'WebSocket connection error' });
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!isUnmounted.current) {
|
||||
setConnectionState({ status: 'disconnected' });
|
||||
// Auto-reconnect after 3 seconds
|
||||
reconnectTimer.current = window.setTimeout(() => {
|
||||
if (!isUnmounted.current) connect();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
} catch (e) {
|
||||
setConnectionState({ status: 'error', error: String(e) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Discover OPLabs plugins via REST API
|
||||
const discover = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/discover');
|
||||
const data: DiscoverResponse = await res.json();
|
||||
setPlugins(data.oplabs_instances || []);
|
||||
setPedalboardName(data.pedalboard_name);
|
||||
setLastUpdate(Date.now());
|
||||
if (data.connected) {
|
||||
setConnectionState({ status: 'connected' });
|
||||
}
|
||||
} catch (e) {
|
||||
setConnectionState({ status: 'error', error: `Discover failed: ${String(e)}` });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Connect on mount
|
||||
useEffect(() => {
|
||||
isUnmounted.current = false;
|
||||
connect();
|
||||
return () => {
|
||||
isUnmounted.current = true;
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Auto-discover on connect
|
||||
useEffect(() => {
|
||||
if (connectionState.status === 'connected') {
|
||||
discover();
|
||||
}
|
||||
}, [connectionState.status, discover]);
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
plugins,
|
||||
pedalboardName,
|
||||
discover,
|
||||
lastUpdate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/** A single scene snapshot of all OPLabs plugin states. */
|
||||
export interface SceneChannelState {
|
||||
instanceId: number;
|
||||
params: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SceneState {
|
||||
channels: SceneChannelState[];
|
||||
buses: SceneChannelState[];
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
id: string;
|
||||
name: string;
|
||||
midiPc: number | null;
|
||||
state: SceneState;
|
||||
}
|
||||
|
||||
export interface UseScenesReturn {
|
||||
scenes: Scene[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
saveScene: (name: string, state: SceneState, midiPc?: number | null) => Promise<void>;
|
||||
updateScene: (id: string, updates: Partial<Pick<Scene, 'name' | 'midiPc' | 'state'>>) => Promise<void>;
|
||||
deleteScene: (id: string) => Promise<void>;
|
||||
recallScene: (id: string) => Promise<boolean>;
|
||||
refreshScenes: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing mixer scenes via the backend REST API.
|
||||
*/
|
||||
export function useScenes(): UseScenesReturn {
|
||||
const [scenes, setScenes] = useState<Scene[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshScenes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/scenes');
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load scenes: ${res.statusText}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setScenes(data.scenes || []);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshScenes();
|
||||
}, [refreshScenes]);
|
||||
|
||||
const saveScene = useCallback(
|
||||
async (name: string, state: SceneState, midiPc?: number | null) => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/scenes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, state, midiPc: midiPc ?? null }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to save scene: ${res.statusText}`);
|
||||
}
|
||||
await refreshScenes();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[refreshScenes]
|
||||
);
|
||||
|
||||
const updateScene = useCallback(
|
||||
async (id: string, updates: Partial<Pick<Scene, 'name' | 'midiPc' | 'state'>>) => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/scenes/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to update scene: ${res.statusText}`);
|
||||
}
|
||||
await refreshScenes();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
}
|
||||
},
|
||||
[refreshScenes]
|
||||
);
|
||||
|
||||
const deleteScene = useCallback(
|
||||
async (id: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/scenes/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to delete scene: ${res.statusText}`);
|
||||
}
|
||||
await refreshScenes();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
}
|
||||
},
|
||||
[refreshScenes]
|
||||
);
|
||||
|
||||
const recallScene = useCallback(async (id: string): Promise<boolean> => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/scenes/${encodeURIComponent(id)}/recall`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Recall failed: ${res.statusText}`);
|
||||
}
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
scenes,
|
||||
loading,
|
||||
error,
|
||||
saveScene,
|
||||
updateScene,
|
||||
deleteScene,
|
||||
recallScene,
|
||||
refreshScenes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './App.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
Reference in New Issue
Block a user