This commit is contained in:
Joey Yakimowich-Payne 2026-01-27 21:49:50 -07:00
commit debc7f1853
5 changed files with 276 additions and 7 deletions

View file

@ -1229,10 +1229,10 @@ private:
### Milestone 5: Mixer View & Volume Control
**Estimated Time:** 1-2 weeks
- [ ] Design traditional mixer UI with faders
- [ ] Implement volume slider with PipeWire parameter sync
- [ ] Add mute buttons and solo functionality
- [ ] Create stereo/multi-channel level meters
- [x] Design traditional mixer UI with faders
- [x] Implement volume slider with PipeWire parameter sync
- [x] Add mute buttons and solo functionality
- [x] Create stereo/multi-channel level meters
- [ ] Implement undo/redo for volume changes
- [ ] **Acceptance Criteria:** Mixer panel controls node volumes, changes persist in presets

View file

@ -11,6 +11,9 @@
#include <QLabel>
#include <QHBoxLayout>
#include <QSplitter>
#include <QTabWidget>
#include <QSlider>
#include <QToolButton>
#include <QTimer>
#include <QEvent>
#include <QVBoxLayout>
@ -69,8 +72,16 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
m_splitter->setOrientation(Qt::Horizontal);
m_splitter->addWidget(m_view);
auto *meterPanel = new QWidget(m_splitter);
meterPanel->setStyleSheet("background-color: #1f2126; border-left: 1px solid #2b2f38;");
m_sidebarTabs = new QTabWidget(m_splitter);
m_sidebarTabs->setStyleSheet(
"QTabWidget::pane { border: none; background: #1f2126; border-left: 1px solid #2b2f38; }"
"QTabBar::tab { background: #1f2126; color: #8c94a5; padding: 8px 12px; border: none; font-weight: 700; font-size: 11px; }"
"QTabBar::tab:selected { color: #dbe2ee; border-bottom: 2px solid #5fcf8d; }"
"QTabBar::tab:hover { color: #c7cfdd; }"
);
auto *meterPanel = new QWidget();
meterPanel->setStyleSheet("background-color: #1f2126;");
meterPanel->setMinimumWidth(260);
meterPanel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
@ -106,7 +117,31 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
meterLayout->addStretch();
m_splitter->addWidget(meterPanel);
m_sidebarTabs->addTab(meterPanel, "METERS");
m_mixerTab = new QWidget();
m_mixerTab->setStyleSheet("background-color: #1f2126;");
auto *mixerTabLayout = new QVBoxLayout(m_mixerTab);
mixerTabLayout->setContentsMargins(0, 0, 0, 0);
auto *mixerScroll = new QScrollArea(m_mixerTab);
mixerScroll->setFrameShape(QFrame::NoFrame);
mixerScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
mixerScroll->setWidgetResizable(true);
m_mixerList = new QWidget(mixerScroll);
m_mixerListLayout = new QHBoxLayout(m_mixerList);
m_mixerListLayout->setContentsMargins(20, 20, 20, 20);
m_mixerListLayout->setSpacing(12);
m_mixerListLayout->setAlignment(Qt::AlignLeft);
m_mixerList->setLayout(m_mixerListLayout);
mixerScroll->setWidget(m_mixerList);
mixerTabLayout->addWidget(mixerScroll);
m_sidebarTabs->addTab(m_mixerTab, "MIXER");
m_splitter->addWidget(m_sidebarTabs);
m_splitter->setStretchFactor(0, 1);
m_splitter->setStretchFactor(1, 0);
@ -288,6 +323,7 @@ void GraphEditorWidget::syncGraph()
if (isAudioEndpoint(node)) {
m_model->addPipeWireNode(node);
refreshNodeMeter(node.id, node);
refreshMixerStrip(node.id, node);
}
}
const QVector<Potato::LinkInfo> links = m_controller->links();
@ -318,6 +354,7 @@ void GraphEditorWidget::onNodeAdded(const Potato::NodeInfo &node)
if (isAudioEndpoint(node)) {
m_model->addPipeWireNode(node);
refreshNodeMeter(node.id, node);
refreshMixerStrip(node.id, node);
}
}
@ -333,6 +370,8 @@ void GraphEditorWidget::onNodeChanged(const Potato::NodeInfo &node)
refreshNodeMeter(node.id, node);
updateNodeMeterState(node.id, node);
refreshMixerStrip(node.id, node);
updateMixerState(node.id, node);
}
void GraphEditorWidget::onNodeRemoved(uint32_t nodeId)
@ -351,6 +390,8 @@ void GraphEditorWidget::onNodeRemoved(uint32_t nodeId)
m_nodeMeters.remove(nodeId);
row->deleteLater();
}
removeMixerStrip(nodeId);
}
void GraphEditorWidget::onLinkAdded(const Potato::LinkInfo &link)
@ -514,6 +555,12 @@ void GraphEditorWidget::updateMeter()
it.value()->setLevel(nodePeak);
}
for (auto it = m_mixerMeters.begin(); it != m_mixerMeters.end(); ++it) {
const uint32_t nodeId = static_cast<uint32_t>(it.key());
const float nodePeak = m_controller->nodeMeterPeak(nodeId);
it.value()->setLevel(nodePeak);
}
if (m_meterProfileReady) {
const qint64 duration = m_meterProfileTimer.nsecsElapsed() - startNanos;
m_meterProfileNanos += duration;
@ -723,3 +770,164 @@ bool GraphEditorWidget::eventFilter(QObject *object, QEvent *event)
return QWidget::eventFilter(object, event);
}
void GraphEditorWidget::refreshMixerStrip(uint32_t nodeId, const Potato::NodeInfo &node)
{
if (m_mixerStrips.contains(nodeId)) {
return;
}
auto *strip = new QWidget(m_mixerList);
strip->setFixedWidth(90);
auto *layout = new QVBoxLayout(strip);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(8);
auto *meter = new AudioLevelMeter(strip);
meter->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
auto *fader = new QSlider(Qt::Vertical, strip);
fader->setRange(0, 100);
fader->setValue(100);
fader->setToolTip("Volume");
fader->setStyleSheet(
"QSlider::groove:vertical { width: 4px; background: #2b2f38; border-radius: 2px; }"
"QSlider::sub-page:vertical { background: #2b2f38; border-radius: 2px; }"
"QSlider::add-page:vertical { background: #5fcf8d; border-radius: 2px; }"
"QSlider::handle:vertical { height: 10px; margin: 0 -6px; background: #dbe2ee; border-radius: 5px; }"
);
auto *muteBtn = new QToolButton(strip);
muteBtn->setText("M");
muteBtn->setCheckable(true);
muteBtn->setFixedSize(24, 24);
muteBtn->setToolTip("Mute");
muteBtn->setStyleSheet(
"QToolButton { background: #2b2f38; color: #8c94a5; border: none; border-radius: 4px; font-weight: 700; }"
"QToolButton:checked { background: #c94f4f; color: #ffffff; }"
"QToolButton:hover { background: #3c4350; }"
);
auto *soloBtn = new QToolButton(strip);
soloBtn->setText("S");
soloBtn->setCheckable(true);
soloBtn->setFixedSize(24, 24);
soloBtn->setToolTip("Solo");
soloBtn->setStyleSheet(
"QToolButton { background: #2b2f38; color: #8c94a5; border: none; border-radius: 4px; font-weight: 700; }"
"QToolButton:checked { background: #e0b045; color: #ffffff; }"
"QToolButton:hover { background: #3c4350; }"
);
auto *label = new QLabel(node.description.isEmpty() ? node.name : node.description, strip);
label->setStyleSheet("color: #8c94a5; font-size: 10px; font-weight: 600;");
label->setAlignment(Qt::AlignCenter);
label->setWordWrap(true);
label->setMinimumHeight(48);
layout->addWidget(meter, 1);
layout->addWidget(fader, 0, Qt::AlignHCenter);
auto *btnLayout = new QHBoxLayout();
btnLayout->setContentsMargins(0, 0, 0, 0);
btnLayout->setSpacing(4);
btnLayout->addWidget(muteBtn);
btnLayout->addWidget(soloBtn);
layout->addLayout(btnLayout);
layout->addWidget(label);
m_mixerListLayout->addWidget(strip);
m_mixerStrips.insert(nodeId, strip);
m_mixerMeters.insert(nodeId, meter);
m_mixerFaders.insert(nodeId, fader);
m_mixerMutes.insert(nodeId, muteBtn);
m_mixerSolos.insert(nodeId, soloBtn);
m_mixerUserMute.insert(nodeId, false);
connect(fader, &QSlider::valueChanged, [this, nodeId](int value) {
if (!m_mixerMutes.contains(nodeId)) {
return;
}
const float volume = static_cast<float>(value) / 100.0f;
const bool userMute = m_mixerUserMute.value(nodeId, false);
const bool soloActive = !m_mixerSoloNodes.isEmpty();
const bool effectiveMute = userMute || (soloActive && !m_mixerSoloNodes.contains(nodeId));
m_controller->setNodeVolume(nodeId, volume, effectiveMute);
if (m_model) {
m_model->setNodeVolumeState(nodeId, NodeVolumeState{volume, userMute});
}
});
connect(muteBtn, &QToolButton::toggled, [this, nodeId](bool checked) {
m_mixerUserMute[nodeId] = checked;
applySoloState();
});
connect(soloBtn, &QToolButton::toggled, [this, nodeId](bool checked) {
if (checked) {
m_mixerSoloNodes.insert(nodeId);
} else {
m_mixerSoloNodes.remove(nodeId);
}
applySoloState();
});
updateMixerState(nodeId, node);
}
void GraphEditorWidget::updateMixerState(uint32_t nodeId, const Potato::NodeInfo &node)
{
if (!m_mixerFaders.contains(nodeId) || !m_mixerMutes.contains(nodeId)) {
return;
}
if (node.stableId.isEmpty() || !m_model) {
return;
}
NodeVolumeState state;
if (!m_model->nodeVolumeState(nodeId, state)) {
return;
}
auto *fader = m_mixerFaders.value(nodeId);
auto *muteBtn = m_mixerMutes.value(nodeId);
fader->blockSignals(true);
muteBtn->blockSignals(true);
fader->setValue(static_cast<int>(state.volume * 100.0f));
muteBtn->setChecked(state.mute);
fader->blockSignals(false);
muteBtn->blockSignals(false);
m_mixerUserMute[nodeId] = state.mute;
applySoloState();
}
void GraphEditorWidget::removeMixerStrip(uint32_t nodeId)
{
if (m_mixerStrips.contains(nodeId)) {
auto *strip = m_mixerStrips.take(nodeId);
m_mixerMeters.remove(nodeId);
m_mixerFaders.remove(nodeId);
m_mixerMutes.remove(nodeId);
m_mixerSolos.remove(nodeId);
m_mixerUserMute.remove(nodeId);
m_mixerSoloNodes.remove(nodeId);
strip->deleteLater();
}
}
void GraphEditorWidget::applySoloState()
{
for (auto it = m_mixerFaders.begin(); it != m_mixerFaders.end(); ++it) {
const uint32_t nodeId = static_cast<uint32_t>(it.key());
const float volume = static_cast<float>(it.value()->value()) / 100.0f;
const bool userMute = m_mixerUserMute.value(nodeId, false);
const bool soloActive = !m_mixerSoloNodes.isEmpty();
const bool effectiveMute = userMute || (soloActive && !m_mixerSoloNodes.contains(nodeId));
m_controller->setNodeVolume(nodeId, volume, effectiveMute);
if (m_model) {
m_model->setNodeVolumeState(nodeId, NodeVolumeState{volume, userMute});
}
}
}

View file

@ -17,7 +17,11 @@ class QLabel;
class QTimer;
class QScrollArea;
class QVBoxLayout;
class QHBoxLayout;
class QSlider;
class QToolButton;
class QSplitter;
class QTabWidget;
class PresetManager;
class GraphEditorWidget : public QWidget
@ -44,6 +48,10 @@ private:
void updateLayoutState();
void updateNodeMeterLabel(QLabel *label);
void updateNodeMeterState(uint32_t nodeId, const Potato::NodeInfo &node);
void refreshMixerStrip(uint32_t nodeId, const Potato::NodeInfo &node);
void updateMixerState(uint32_t nodeId, const Potato::NodeInfo &node);
void removeMixerStrip(uint32_t nodeId);
void applySoloState();
void handleLinkRemoved(uint32_t linkId);
bool isMeterNode(uint32_t nodeId) const;
int activeLinkCount(uint32_t nodeId) const;
@ -57,6 +65,18 @@ private:
QtNodes::GraphicsView *m_view = nullptr;
QSplitter *m_splitter = nullptr;
QTabWidget *m_sidebarTabs = nullptr;
QWidget *m_mixerTab = nullptr;
QWidget *m_mixerList = nullptr;
QHBoxLayout *m_mixerListLayout = nullptr;
QMap<uint32_t, AudioLevelMeter*> m_mixerMeters;
QMap<uint32_t, QWidget*> m_mixerStrips;
QMap<uint32_t, QSlider*> m_mixerFaders;
QMap<uint32_t, QToolButton*> m_mixerMutes;
QMap<uint32_t, QToolButton*> m_mixerSolos;
QMap<uint32_t, bool> m_mixerUserMute;
QSet<uint32_t> m_mixerSoloNodes;
QSet<QString> m_ignoreCreate;
QSet<QString> m_ignoreDelete;
QSet<uint32_t> m_ignoreLinkRemoved;

View file

@ -830,6 +830,45 @@ void PipeWireGraphModel::applyVolumeStates(const QHash<QString, NodeVolumeState>
}
}
void PipeWireGraphModel::setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state)
{
m_nodeVolumeState.insert(nodeId, state);
auto nodeIt = m_pwToNode.find(nodeId);
if (nodeIt == m_pwToNode.end()) {
return;
}
auto widgetIt = m_nodeWidgets.find(nodeIt->second);
if (widgetIt == m_nodeWidgets.end()) {
return;
}
QWidget *widget = widgetIt->second;
if (!widget) {
return;
}
if (auto *slider = widget->findChild<QSlider*>()) {
slider->blockSignals(true);
slider->setValue(static_cast<int>(state.volume * 100.0f));
slider->blockSignals(false);
}
if (auto *button = widget->findChild<QToolButton*>()) {
button->blockSignals(true);
button->setChecked(state.mute);
button->blockSignals(false);
}
}
bool PipeWireGraphModel::nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const
{
if (!m_nodeVolumeState.contains(nodeId)) {
return false;
}
state = m_nodeVolumeState.value(nodeId);
return true;
}
void PipeWireGraphModel::saveLayout() const
{
const QString path = layoutFilePath();

View file

@ -81,6 +81,8 @@ public:
void applyLayoutJson(const QJsonObject &root);
QHash<QString, NodeVolumeState> volumeStates() const;
void applyVolumeStates(const QHash<QString, NodeVolumeState> &states);
void setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state);
bool nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const;
private:
QWidget *nodeWidget(QtNodes::NodeId nodeId) const;