feat: Mixer Panel display-only with F7 toggle

Add MixerStrip component for display-only track strips (no audio
routing). Mixer panel at bottom with drag resize handle (80-400px).
F7 keyboard shortcut, status bar toggle button, View menu item.
onUpdateTrack handles mute/solo/volume changes directly on tracks.
This commit is contained in:
2026-07-29 09:49:47 +07:00
parent 33cf804c88
commit 2187440681
3 changed files with 151 additions and 9 deletions
+133 -3
View File
@@ -371,6 +371,52 @@ const VolumeKnob = ({
strokeLinecap: "round" strokeLinecap: "round"
})))); }))));
}; };
const MixerStrip = ({ track, index, onUpdateTrack }) => {
const dbLabel = (track.volumeDb == null || track.volumeDb <= -50) ? '-inf' : (track.volumeDb > 0 ? '+' : '') + (track.volumeDb || 0).toFixed(1) + 'dB';
const panLabel = track.pan === 0 ? 'C' : (track.pan < 0 ? 'L' + Math.round(Math.abs(track.pan)) : 'R' + Math.round(track.pan));
const isMuted = track.muted;
const isSoloed = track.solo;
const panDeg = (track.pan || 0) * 1.35;
return React.createElement("div", {
className: "flex flex-col items-stretch w-14 shrink-0 bg-[#2b2b2b] border border-black/70 overflow-hidden rounded-sm"
},
React.createElement("div", {
className: "flex items-center justify-between px-1 py-0.5 bg-[#222] border-b border-black/60 shrink-0"
}, React.createElement("span", {
className: "text-[9px] font-mono font-bold text-zinc-400"
}, (index + 1))),
React.createElement("div", {
className: "flex items-center justify-center gap-1 py-0.5 shrink-0"
}, React.createElement("button", {
onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { muted: !track.muted }); },
title: "Mute",
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isMuted ? 'bg-orange-500 text-black border-orange-400' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
}, "M"), React.createElement("button", {
onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); },
title: "Solo",
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isSoloed ? 'bg-yellow-400 text-black border-yellow-300' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
}, "S")),
React.createElement("div", {
className: "flex-1 flex items-end justify-center gap-1 px-1 py-1 min-h-0"
}, React.createElement("input", {
type: "range",
min: "-60",
max: "12",
step: "0.5",
value: track.volumeDb != null ? track.volumeDb : 0,
onChange: e => { if (onUpdateTrack) onUpdateTrack(track.id, { volumeDb: parseFloat(e.target.value) }); },
className: "w-3 bg-[#161616] rounded appearance-none cursor-pointer accent-orange-500 border border-black/60",
style: { writingMode: 'vertical-lr', direction: 'rtl' }
}), React.createElement("div", {
className: "w-2.5 rounded-sm bg-[#0d0d0d] border border-black/60"
})),
React.createElement("div", {
className: "text-center text-[9px] font-mono font-bold py-0.5 " + ((track.volumeDb || 0) > 0 ? 'text-orange-400' : 'text-zinc-300') + " bg-[#1c1c1c] border-t border-black/50 shrink-0"
}, dbLabel),
React.createElement("div", {
className: "text-[8px] font-mono text-zinc-400 truncate w-full text-center px-1 py-0.5 bg-[#222] border-t border-black/60 shrink-0"
}, track.name));
};
const WaveformLane = ({ const WaveformLane = ({
track, track,
zoom, zoom,
@@ -7174,6 +7220,13 @@ const App = () => {
scrollBufferExtraRef.current = scrollBufferExtra; scrollBufferExtraRef.current = scrollBufferExtra;
const [showFxRack, setShowFxRack] = useState(false); const [showFxRack, setShowFxRack] = useState(false);
const [showMidiEvents, setShowMidiEvents] = useState(false); const [showMidiEvents, setShowMidiEvents] = useState(false);
const [showMixer, setShowMixer] = useState(false);
const setShowMixerRef = useRef(setShowMixer);
setShowMixerRef.current = setShowMixer;
const [mixerHeight, setMixerHeight] = useState(function() {
var saved = localStorage.getItem('studio_mixer_height');
return saved ? parseInt(saved) : 200;
}());
const [rightSidebarWidth, setRightSidebarWidth] = useState(320); const [rightSidebarWidth, setRightSidebarWidth] = useState(320);
const [tcpWidth, setTcpWidth] = useState(320); const [tcpWidth, setTcpWidth] = useState(320);
const [mediaExplorerHeight, setMediaExplorerHeight] = useState(50); const [mediaExplorerHeight, setMediaExplorerHeight] = useState(50);
@@ -8330,6 +8383,11 @@ const App = () => {
handleSplitTrackRef.current(selectedTrackIdRef.current); handleSplitTrackRef.current(selectedTrackIdRef.current);
return; return;
} }
if (e.key === 'F7') {
e.preventDefault();
setShowMixerRef.current(p => !p);
return;
}
}; };
window.addEventListener('keydown', handler, { capture: true }); window.addEventListener('keydown', handler, { capture: true });
return () => window.removeEventListener('keydown', handler, { capture: true }); return () => window.removeEventListener('keydown', handler, { capture: true });
@@ -12426,6 +12484,10 @@ const App = () => {
}); });
setTimeout(() => lucide.createIcons(), 50); setTimeout(() => lucide.createIcons(), 50);
}; };
const updateTrackProp = (trackId, props) => {
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, ...props } : t));
setTimeout(() => lucide.createIcons(), 50);
};
const toggleTrackDrum = trackId => { const toggleTrackDrum = trackId => {
setTracks(prev => prev.map(t => t.id === trackId ? { setTracks(prev => prev.map(t => t.id === trackId ? {
...t, ...t,
@@ -15314,7 +15376,7 @@ const App = () => {
}, { }, {
label: 'Mixer', label: 'Mixer',
icon: 'sliders', icon: 'sliders',
action: () => showToast('Mixer panel', 'info') action: () => setShowMixer(p => !p)
}, { }, {
label: 'Tempo Track', label: 'Tempo Track',
icon: 'timer', icon: 'timer',
@@ -17713,7 +17775,66 @@ const App = () => {
})())), /*#__PURE__*/React.createElement("div", { })())), /*#__PURE__*/React.createElement("div", {
className: "w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10", className: "w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",
onMouseDown: startColResize onMouseDown: startColResize
}), renderDock('right', 'Right')), renderDock('bottom', 'Bottom')); }), renderDock('right', 'Right')), renderDock('bottom', 'Bottom'), showMixer && /*#__PURE__*/React.createElement("div", {
className: "flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none",
style: { height: mixerHeight + 'px' }
}, /*#__PURE__*/React.createElement("div", {
className: "flex items-center justify-between px-2 py-0.5 bg-zinc-800/80 shrink-0 cursor-ns-resize",
onMouseDown: e => {
e.preventDefault();
var startY = e.clientY;
var startH = mixerHeight;
var onMove = function(ev) {
var newH = Math.max(80, Math.min(400, startH - (ev.clientY - startY)));
setMixerHeight(newH);
localStorage.setItem('studio_mixer_height', newH.toString());
};
var onUp = function() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
}, /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-2"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "grip-horizontal",
className: "w-3 h-3 text-zinc-600"
})), /*#__PURE__*/React.createElement("span", {
className: "text-[10px] font-bold text-zinc-400 uppercase tracking-wider"
}, "MIXER")), /*#__PURE__*/React.createElement("button", {
onClick: () => setShowMixer(false),
className: "p-0.5 rounded text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700 transition"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "x",
className: "w-3 h-3"
})))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex overflow-x-auto p-1.5 gap-1.5 items-stretch"
}, /*#__PURE__*/React.createElement("div", {
className: "flex flex-col items-center justify-between w-14 shrink-0 bg-[#2b2b2b] border border-black/70 overflow-hidden rounded-sm"
}, /*#__PURE__*/React.createElement("div", {
className: "text-[9px] font-bold text-zinc-300 uppercase tracking-wider w-full text-center py-0.5 bg-[#222] border-b border-black/60"
}, "MASTER"), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex items-end justify-center w-full px-1 py-1"
}, /*#__PURE__*/React.createElement("div", {
className: "w-2.5 rounded-sm bg-[#0d0d0d] border border-black/60"
})), /*#__PURE__*/React.createElement("div", {
className: "text-[8px] font-mono font-bold text-zinc-500 w-full text-center py-0.5 bg-[#222] border-t border-black/60"
}, "MAIN OUT")), activeTracks.length > 0 && /*#__PURE__*/React.createElement("div", {
className: "w-px bg-zinc-700 shrink-0 self-stretch mx-0.5"
}), activeTracks.map(function(track, idx) {
return /*#__PURE__*/React.createElement(MixerStrip, {
key: track.id,
track: track,
index: idx,
onUpdateTrack: updateTrackProp
});
}))));
})(), /*#__PURE__*/React.createElement("div", { })(), /*#__PURE__*/React.createElement("div", {
className: "h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-xs text-zinc-500 select-none shrink-0" className: "h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-xs text-zinc-500 select-none shrink-0"
}, /*#__PURE__*/React.createElement("div", { }, /*#__PURE__*/React.createElement("div", {
@@ -17789,7 +17910,16 @@ const App = () => {
className: "w-3 h-3" className: "w-3 h-3"
})), /*#__PURE__*/React.createElement("span", { })), /*#__PURE__*/React.createElement("span", {
className: "text-[7px] opacity-60" className: "text-[7px] opacity-60"
}, showMidiEvents ? (panelPositions.midi_events || 'bottom')[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("span", { }, showMidiEvents ? (panelPositions.midi_events || 'bottom')[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", {
onClick: () => setShowMixer(p => !p),
className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMixer ? 'bg-indigo-900 text-indigo-300' : 'text-zinc-500 hover:text-zinc-300'}`,
title: "Mixer Panel (F7)"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "sliders-horizontal",
className: "w-3 h-3"
}))), /*#__PURE__*/React.createElement("span", {
className: "w-[1px] h-3 bg-zinc-800 mx-1" className: "w-[1px] h-3 bg-zinc-800 mx-1"
}), /*#__PURE__*/React.createElement("span", { }), /*#__PURE__*/React.createElement("span", {
className: "flex items-center gap-1" className: "flex items-center gap-1"
File diff suppressed because one or more lines are too long
+12
View File
@@ -670,3 +670,15 @@
- **Các file ảnh hưởng:** `app/static/js/app.jsx` - **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** Build có lỗi parse pre-existing (Babel 8) không liên quan đến thay đổi này. - **Ghi chú/Test (nếu có):** Build có lỗi parse pre-existing (Babel 8) không liên quan đến thay đổi này.
--- ---
### [2026-07-29 09:40] Task: Reset to 33cf804 — clean slate for mixer reimplementation
- **Tóm tắt thay đổi:** Hard reset về commit 33cf804, xóa toàn bộ mixer code (MasterChannelStrip, TrackChannelStrip, MixerConsole, AudioMixerGraphManager, setOutputNode) và MIDI keyboard note sustain. Giữ lại TimelineRuler (tempo bar/timebar/lead-in/0-index). Backup mixer panel code lưu trong `mixer_panel_backup_*.patch`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/services/soundfontPlayer.js`, `app/static/js/services/audioMixerGraphManager.js`
- **Ghi chú/Test (nếu có):** `npm run build` — build passes. Sẵn sàng cài đặt lại mixer panel display-only.
---
### [2026-07-29 09:46] Task: Install Mixer Panel display-only with F7 toggle
- **Tóm tắt thay đổi:** Thêm MixerStrip component (display-only, không audio routing). Mixer panel bottom dock với drag handle resize (80-400px). Track strip hiển thị index, M/S buttons, fader slider, dB readout, track name. Master strip với "MAIN OUT" label. F7 toggle + status bar button + View menu item. onUpdateTrack thay đổi mute/solo/volumeDb trực tiếp.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
---