Compare commits
10 commits
4f240b128b
...
f681b69467
| Author | SHA1 | Date | |
|---|---|---|---|
| f681b69467 | |||
| adab645c86 | |||
| debc7f1853 | |||
| f57d39af48 | |||
| b3a1d2b7f3 | |||
| ecfb59501a | |||
| 96e1a5cbdb | |||
| 6d74ef422d | |||
| 7f6df30c9e | |||
| 8e69c26688 |
11 changed files with 1587 additions and 62 deletions
|
|
@ -33,7 +33,7 @@ pkg_check_modules(SPA REQUIRED libspa-0.2>=0.2)
|
||||||
add_compile_options(
|
add_compile_options(
|
||||||
-Wall
|
-Wall
|
||||||
-Wextra
|
-Wextra
|
||||||
-Wpedantic
|
-Wno-pedantic
|
||||||
-Werror=return-type
|
-Werror=return-type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -90,6 +90,7 @@ add_executable(potato-gui
|
||||||
src/gui/GraphEditorWidget.cpp
|
src/gui/GraphEditorWidget.cpp
|
||||||
src/gui/PipeWireGraphModel.cpp
|
src/gui/PipeWireGraphModel.cpp
|
||||||
src/meters/AudioLevelMeter.cpp
|
src/meters/AudioLevelMeter.cpp
|
||||||
|
src/presets/PresetManager.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(potato-gui PRIVATE
|
target_link_libraries(potato-gui PRIVATE
|
||||||
|
|
|
||||||
|
|
@ -1191,52 +1191,52 @@ private:
|
||||||
|
|
||||||
## 11. Implementation Milestone Plan
|
## 11. Implementation Milestone Plan
|
||||||
|
|
||||||
### Milestone 1: Core PipeWire Integration ✅
|
### Milestone 1: Core PipeWire Integration
|
||||||
**Estimated Time:** 2-3 weeks
|
**Estimated Time:** 2-3 weeks
|
||||||
- [ ] Initialize Qt6 project with CMake
|
- [x] Initialize Qt6 project with CMake
|
||||||
- [ ] Integrate libpipewire with `pw_thread_loop`
|
- [x] Integrate libpipewire with `pw_thread_loop`
|
||||||
- [ ] Implement node/port discovery via registry callbacks
|
- [x] Implement node/port discovery via registry callbacks
|
||||||
- [ ] Implement link creation/destruction
|
- [x] Implement link creation/destruction
|
||||||
- [ ] Create lock-free communication primitives (atomics, ring buffers)
|
- [x] Create lock-free communication primitives (atomics, ring buffers)
|
||||||
- [ ] **Acceptance Criteria:** CLI test app that lists nodes and creates a link programmatically
|
- [x] **Acceptance Criteria:** CLI test app that lists nodes and creates a link programmatically
|
||||||
|
|
||||||
### Milestone 2: QtNodes Integration ✅
|
### Milestone 2: QtNodes Integration
|
||||||
**Estimated Time:** 2-3 weeks
|
**Estimated Time:** 2-3 weeks
|
||||||
- [ ] Integrate QtNodes library (submodule or CMake package)
|
- [x] Integrate QtNodes library (submodule or CMake package)
|
||||||
- [ ] Create `AudioNodeDataModel` for PipeWire nodes
|
- [x] Create `AudioNodeDataModel` for PipeWire nodes
|
||||||
- [ ] Map PipeWire ports to QtNodes handles
|
- [x] Map PipeWire ports to QtNodes handles
|
||||||
- [ ] Implement connection validation
|
- [x] Implement connection validation
|
||||||
- [ ] Create custom node widgets with embedded controls
|
- [x] Create custom node widgets with embedded controls
|
||||||
- [ ] **Acceptance Criteria:** Visual graph editor displays PipeWire nodes and allows dragging connections
|
- [x] **Acceptance Criteria:** Visual graph editor displays PipeWire nodes and allows dragging connections
|
||||||
|
|
||||||
### Milestone 3: Real-Time Meters & Performance ✅
|
### Milestone 3: Real-Time Meters & Performance
|
||||||
**Estimated Time:** 1-2 weeks
|
**Estimated Time:** 1-2 weeks
|
||||||
- [ ] Implement `AudioLevelMeter` with optimized QGraphicsView
|
- [x] Implement `AudioLevelMeter` with optimized QGraphicsView
|
||||||
- [ ] Create 30Hz update timer with manual viewport control
|
- [x] Create 30Hz update timer with manual viewport control
|
||||||
- [ ] Integrate PipeWire audio callbacks for meter data
|
- [x] Integrate PipeWire audio callbacks for meter data
|
||||||
- [ ] Implement lock-free meter data transfer (atomics)
|
- [x] Implement lock-free meter data transfer (atomics)
|
||||||
- [ ] Profile and optimize rendering performance
|
- [x] Profile and optimize rendering performance
|
||||||
- [ ] **Acceptance Criteria:** Smooth 30Hz meters with no GUI lag, validated with profiler
|
- [ ] **Acceptance Criteria:** Smooth 30Hz meters with no GUI lag, validated with profiler
|
||||||
|
|
||||||
### Milestone 4: Virtual Devices & State Management ✅
|
### Milestone 4: Virtual Devices & State Management
|
||||||
**Estimated Time:** 2 weeks
|
**Estimated Time:** 2 weeks
|
||||||
- [ ] Implement virtual sink/source creation via PipeWire adapters
|
- [x] Implement virtual sink/source creation via PipeWire adapters
|
||||||
- [ ] Create `PresetManager` with JSON serialization
|
- [x] Create `PresetManager` with JSON serialization
|
||||||
- [ ] Implement preset load/save functionality
|
- [x] Implement preset load/save functionality
|
||||||
- [ ] Store UI layout alongside audio graph state
|
- [x] Store UI layout alongside audio graph state
|
||||||
- [ ] Implement auto-reconnect for device hotplug
|
- [ ] Implement auto-reconnect for device hotplug
|
||||||
- [ ] **Acceptance Criteria:** Create virtual device, save preset, restore on restart
|
- [ ] **Acceptance Criteria:** Create virtual device, save preset, restore on restart
|
||||||
|
|
||||||
### Milestone 5: Mixer View & Volume Control ✅
|
### Milestone 5: Mixer View & Volume Control
|
||||||
**Estimated Time:** 1-2 weeks
|
**Estimated Time:** 1-2 weeks
|
||||||
- [ ] Design traditional mixer UI with faders
|
- [x] Design traditional mixer UI with faders
|
||||||
- [ ] Implement volume slider with PipeWire parameter sync
|
- [x] Implement volume slider with PipeWire parameter sync
|
||||||
- [ ] Add mute buttons and solo functionality
|
- [x] Add mute buttons and solo functionality
|
||||||
- [ ] Create stereo/multi-channel level meters
|
- [x] Create stereo/multi-channel level meters
|
||||||
- [ ] Implement undo/redo for volume changes
|
- [x] Implement undo/redo for volume changes
|
||||||
- [ ] **Acceptance Criteria:** Mixer panel controls node volumes, changes persist in presets
|
- [ ] **Acceptance Criteria:** Mixer panel controls node volumes, changes persist in presets
|
||||||
|
|
||||||
### Milestone 6: Undo/Redo & Polish ✅
|
### Milestone 6: Undo/Redo & Polish
|
||||||
**Estimated Time:** 1-2 weeks
|
**Estimated Time:** 1-2 weeks
|
||||||
- [ ] Integrate QUndoStack for all graph operations
|
- [ ] Integrate QUndoStack for all graph operations
|
||||||
- [ ] Implement command classes for link, volume, node operations
|
- [ ] Implement command classes for link, volume, node operations
|
||||||
|
|
@ -1245,7 +1245,7 @@ private:
|
||||||
- [ ] Add copy/paste/duplicate functionality
|
- [ ] Add copy/paste/duplicate functionality
|
||||||
- [ ] **Acceptance Criteria:** Full undo/redo history, keyboard shortcuts work
|
- [ ] **Acceptance Criteria:** Full undo/redo history, keyboard shortcuts work
|
||||||
|
|
||||||
### Milestone 7: Error Handling & Edge Cases ✅
|
### Milestone 7: Error Handling & Edge Cases
|
||||||
**Estimated Time:** 1-2 weeks
|
**Estimated Time:** 1-2 weeks
|
||||||
- [ ] Implement device unplug/replug detection
|
- [ ] Implement device unplug/replug detection
|
||||||
- [ ] Handle PipeWire service restart with auto-reconnect
|
- [ ] Handle PipeWire service restart with auto-reconnect
|
||||||
|
|
@ -1254,7 +1254,7 @@ private:
|
||||||
- [ ] Add error logging with structured JSON output
|
- [ ] Add error logging with structured JSON output
|
||||||
- [ ] **Acceptance Criteria:** App survives device unplug and PipeWire restart without crashing
|
- [ ] **Acceptance Criteria:** App survives device unplug and PipeWire restart without crashing
|
||||||
|
|
||||||
### Milestone 8: Final Polish & Release ✅
|
### Milestone 8: Final Polish & Release
|
||||||
**Estimated Time:** 1-2 weeks
|
**Estimated Time:** 1-2 weeks
|
||||||
- [ ] Create application icon and desktop file
|
- [ ] Create application icon and desktop file
|
||||||
- [ ] Implement dark/light theme support
|
- [ ] Implement dark/light theme support
|
||||||
|
|
|
||||||
27
src/gui/ClickSlider.h
Normal file
27
src/gui/ClickSlider.h
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QStyle>
|
||||||
|
|
||||||
|
class ClickSlider : public QSlider
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
using QSlider::QSlider;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void mousePressEvent(QMouseEvent *event) override
|
||||||
|
{
|
||||||
|
if (event->button() == Qt::LeftButton) {
|
||||||
|
setProperty("pressValue", value());
|
||||||
|
const int span = (orientation() == Qt::Horizontal) ? width() : height();
|
||||||
|
const int pos = (orientation() == Qt::Horizontal)
|
||||||
|
? static_cast<int>(event->position().x())
|
||||||
|
: static_cast<int>(height() - event->position().y());
|
||||||
|
const int value = QStyle::sliderValueFromPosition(minimum(), maximum(), pos, span);
|
||||||
|
setValue(value);
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
QSlider::mousePressEvent(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,23 +1,70 @@
|
||||||
#include "GraphEditorWidget.h"
|
#include "GraphEditorWidget.h"
|
||||||
#include "meters/AudioLevelMeter.h"
|
#include "meters/AudioLevelMeter.h"
|
||||||
|
#include "presets/PresetManager.h"
|
||||||
|
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
|
#include <QDebug>
|
||||||
#include <QColor>
|
#include <QColor>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFontMetrics>
|
#include <QFontMetrics>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QSplitter>
|
#include <QSplitter>
|
||||||
|
#include <QTabWidget>
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QToolButton>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
#include <QUndoCommand>
|
||||||
#include <QEvent>
|
#include <QEvent>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QScrollArea>
|
#include <QScrollArea>
|
||||||
#include <QSizePolicy>
|
#include <QSizePolicy>
|
||||||
|
#include <QElapsedTimer>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
#include <QtNodes/GraphicsViewStyle>
|
#include <QtNodes/GraphicsViewStyle>
|
||||||
#include <QtNodes/NodeStyle>
|
#include <QtNodes/NodeStyle>
|
||||||
|
|
||||||
|
#include "gui/ClickSlider.h"
|
||||||
|
|
||||||
|
class VolumeChangeCommand : public QUndoCommand
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
VolumeChangeCommand(GraphEditorWidget *widget,
|
||||||
|
uint32_t nodeId,
|
||||||
|
const NodeVolumeState &previous,
|
||||||
|
const NodeVolumeState &next)
|
||||||
|
: m_widget(widget)
|
||||||
|
, m_nodeId(nodeId)
|
||||||
|
, m_previous(previous)
|
||||||
|
, m_next(next)
|
||||||
|
{
|
||||||
|
setText(QString("Volume Change"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void undo() override
|
||||||
|
{
|
||||||
|
if (m_widget) {
|
||||||
|
m_widget->applyVolumeState(m_nodeId, m_previous, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void redo() override
|
||||||
|
{
|
||||||
|
if (m_widget) {
|
||||||
|
m_widget->applyVolumeState(m_nodeId, m_next, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
GraphEditorWidget *m_widget = nullptr;
|
||||||
|
uint32_t m_nodeId = 0;
|
||||||
|
NodeVolumeState m_previous{};
|
||||||
|
NodeVolumeState m_next{};
|
||||||
|
};
|
||||||
|
|
||||||
GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent)
|
GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
, m_controller(controller)
|
, m_controller(controller)
|
||||||
|
|
@ -65,8 +112,16 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
|
||||||
m_splitter->setOrientation(Qt::Horizontal);
|
m_splitter->setOrientation(Qt::Horizontal);
|
||||||
m_splitter->addWidget(m_view);
|
m_splitter->addWidget(m_view);
|
||||||
|
|
||||||
auto *meterPanel = new QWidget(m_splitter);
|
m_sidebarTabs = new QTabWidget(m_splitter);
|
||||||
meterPanel->setStyleSheet("background-color: #1f2126; border-left: 1px solid #2b2f38;");
|
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->setMinimumWidth(260);
|
||||||
meterPanel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
|
meterPanel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
|
||||||
|
|
||||||
|
|
@ -102,7 +157,31 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
|
||||||
|
|
||||||
meterLayout->addStretch();
|
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(0, 1);
|
||||||
m_splitter->setStretchFactor(1, 0);
|
m_splitter->setStretchFactor(1, 0);
|
||||||
|
|
||||||
|
|
@ -115,6 +194,16 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
|
||||||
this, &GraphEditorWidget::onConnectionCreated);
|
this, &GraphEditorWidget::onConnectionCreated);
|
||||||
connect(m_model, &PipeWireGraphModel::connectionDeleted,
|
connect(m_model, &PipeWireGraphModel::connectionDeleted,
|
||||||
this, &GraphEditorWidget::onConnectionDeleted);
|
this, &GraphEditorWidget::onConnectionDeleted);
|
||||||
|
connect(m_model, &PipeWireGraphModel::nodeVolumeChanged,
|
||||||
|
this, [this](uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState ¤t) {
|
||||||
|
if (m_ignoreVolumeUndo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushVolumeCommand(nodeId, previous, current);
|
||||||
|
if (m_mixerFaders.contains(nodeId)) {
|
||||||
|
m_mixerLastState.insert(nodeId, current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
connect(m_controller, &Potato::PipeWireController::nodeAdded,
|
connect(m_controller, &Potato::PipeWireController::nodeAdded,
|
||||||
this, &GraphEditorWidget::onNodeAdded);
|
this, &GraphEditorWidget::onNodeAdded);
|
||||||
|
|
@ -172,6 +261,61 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
|
||||||
});
|
});
|
||||||
m_view->addAction(resetLayoutAction);
|
m_view->addAction(resetLayoutAction);
|
||||||
|
|
||||||
|
|
||||||
|
auto *savePresetAction = new QAction(QString("Save Preset..."), m_view);
|
||||||
|
connect(savePresetAction, &QAction::triggered, [this]() {
|
||||||
|
const QString filePath = QFileDialog::getSaveFileName(this,
|
||||||
|
QString("Save Preset"),
|
||||||
|
QString(),
|
||||||
|
QString("Preset Files (*.json)"));
|
||||||
|
if (filePath.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!m_presetManager->savePreset(filePath)) {
|
||||||
|
qWarning() << "Failed to save preset" << filePath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
m_view->addAction(savePresetAction);
|
||||||
|
|
||||||
|
auto *loadPresetAction = new QAction(QString("Load Preset..."), m_view);
|
||||||
|
connect(loadPresetAction, &QAction::triggered, [this]() {
|
||||||
|
const QString filePath = QFileDialog::getOpenFileName(this,
|
||||||
|
QString("Load Preset"),
|
||||||
|
QString(),
|
||||||
|
QString("Preset Files (*.json)"));
|
||||||
|
if (filePath.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!m_presetManager->loadPreset(filePath)) {
|
||||||
|
qWarning() << "Failed to load preset" << filePath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reloadGraphFromController();
|
||||||
|
});
|
||||||
|
m_view->addAction(loadPresetAction);
|
||||||
|
|
||||||
|
auto *createVirtualSinkAction = new QAction(QString("Create Virtual Sink"), m_view);
|
||||||
|
connect(createVirtualSinkAction, &QAction::triggered, [this]() {
|
||||||
|
const int index = ++m_virtualSinkCount;
|
||||||
|
const QString name = QString("Potato_Virtual_Sink_%1").arg(index);
|
||||||
|
const QString description = QString("Virtual Sink %1").arg(index);
|
||||||
|
if (!m_controller->createVirtualSink(name, description, 2, 48000)) {
|
||||||
|
qWarning() << "Failed to create virtual sink" << name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
m_view->addAction(createVirtualSinkAction);
|
||||||
|
|
||||||
|
auto *createVirtualSourceAction = new QAction(QString("Create Virtual Source"), m_view);
|
||||||
|
connect(createVirtualSourceAction, &QAction::triggered, [this]() {
|
||||||
|
const int index = ++m_virtualSourceCount;
|
||||||
|
const QString name = QString("Potato_Virtual_Source_%1").arg(index);
|
||||||
|
const QString description = QString("Virtual Source %1").arg(index);
|
||||||
|
if (!m_controller->createVirtualSource(name, description, 2, 48000)) {
|
||||||
|
qWarning() << "Failed to create virtual source" << name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
m_view->addAction(createVirtualSourceAction);
|
||||||
|
|
||||||
syncGraph();
|
syncGraph();
|
||||||
if (m_model->hasOverlaps()) {
|
if (m_model->hasOverlaps()) {
|
||||||
m_model->autoArrange();
|
m_model->autoArrange();
|
||||||
|
|
@ -205,6 +349,11 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
|
||||||
m_meterTimer->setTimerType(Qt::PreciseTimer);
|
m_meterTimer->setTimerType(Qt::PreciseTimer);
|
||||||
connect(m_meterTimer, &QTimer::timeout, this, &GraphEditorWidget::updateMeter);
|
connect(m_meterTimer, &QTimer::timeout, this, &GraphEditorWidget::updateMeter);
|
||||||
m_meterTimer->start();
|
m_meterTimer->start();
|
||||||
|
|
||||||
|
m_meterProfileTimer.start();
|
||||||
|
m_meterProfileReady = true;
|
||||||
|
|
||||||
|
m_presetManager = new PresetManager(m_controller, m_model, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool isAudioEndpoint(const Potato::NodeInfo &node)
|
static bool isAudioEndpoint(const Potato::NodeInfo &node)
|
||||||
|
|
@ -225,6 +374,7 @@ void GraphEditorWidget::syncGraph()
|
||||||
if (isAudioEndpoint(node)) {
|
if (isAudioEndpoint(node)) {
|
||||||
m_model->addPipeWireNode(node);
|
m_model->addPipeWireNode(node);
|
||||||
refreshNodeMeter(node.id, node);
|
refreshNodeMeter(node.id, node);
|
||||||
|
refreshMixerStrip(node.id, node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const QVector<Potato::LinkInfo> links = m_controller->links();
|
const QVector<Potato::LinkInfo> links = m_controller->links();
|
||||||
|
|
@ -255,6 +405,7 @@ void GraphEditorWidget::onNodeAdded(const Potato::NodeInfo &node)
|
||||||
if (isAudioEndpoint(node)) {
|
if (isAudioEndpoint(node)) {
|
||||||
m_model->addPipeWireNode(node);
|
m_model->addPipeWireNode(node);
|
||||||
refreshNodeMeter(node.id, node);
|
refreshNodeMeter(node.id, node);
|
||||||
|
refreshMixerStrip(node.id, node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,6 +421,8 @@ void GraphEditorWidget::onNodeChanged(const Potato::NodeInfo &node)
|
||||||
|
|
||||||
refreshNodeMeter(node.id, node);
|
refreshNodeMeter(node.id, node);
|
||||||
updateNodeMeterState(node.id, node);
|
updateNodeMeterState(node.id, node);
|
||||||
|
refreshMixerStrip(node.id, node);
|
||||||
|
updateMixerState(node.id, node);
|
||||||
}
|
}
|
||||||
|
|
||||||
void GraphEditorWidget::onNodeRemoved(uint32_t nodeId)
|
void GraphEditorWidget::onNodeRemoved(uint32_t nodeId)
|
||||||
|
|
@ -288,6 +441,8 @@ void GraphEditorWidget::onNodeRemoved(uint32_t nodeId)
|
||||||
m_nodeMeters.remove(nodeId);
|
m_nodeMeters.remove(nodeId);
|
||||||
row->deleteLater();
|
row->deleteLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeMixerStrip(nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void GraphEditorWidget::onLinkAdded(const Potato::LinkInfo &link)
|
void GraphEditorWidget::onLinkAdded(const Potato::LinkInfo &link)
|
||||||
|
|
@ -383,6 +538,7 @@ void GraphEditorWidget::onConnectionCreated(QtNodes::ConnectionId const connecti
|
||||||
|
|
||||||
const uint32_t linkId = m_controller->createLink(outInfo->id, outputPortId, inInfo->id, inputPortId);
|
const uint32_t linkId = m_controller->createLink(outInfo->id, outputPortId, inInfo->id, inputPortId);
|
||||||
if (linkId == 0) {
|
if (linkId == 0) {
|
||||||
|
m_model->deleteConnection(connectionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,6 +591,12 @@ void GraphEditorWidget::updateMeter()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qint64 startNanos = m_meterProfileReady ? m_meterProfileTimer.nsecsElapsed() : 0;
|
||||||
|
|
||||||
const float peak = m_controller->meterPeak();
|
const float peak = m_controller->meterPeak();
|
||||||
m_meter->setLevel(peak);
|
m_meter->setLevel(peak);
|
||||||
|
|
||||||
|
|
@ -443,6 +605,27 @@ void GraphEditorWidget::updateMeter()
|
||||||
const float nodePeak = m_controller->nodeMeterPeak(nodeId);
|
const float nodePeak = m_controller->nodeMeterPeak(nodeId);
|
||||||
it.value()->setLevel(nodePeak);
|
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;
|
||||||
|
m_meterProfileMax = std::max(m_meterProfileMax, duration);
|
||||||
|
++m_meterProfileFrames;
|
||||||
|
if (m_meterProfileFrames >= 300) {
|
||||||
|
const double avgMs = static_cast<double>(m_meterProfileNanos) / (1000000.0 * m_meterProfileFrames);
|
||||||
|
const double maxMs = static_cast<double>(m_meterProfileMax) / 1000000.0;
|
||||||
|
qInfo() << "Meter update avg" << avgMs << "ms max" << maxMs << "ms";
|
||||||
|
m_meterProfileNanos = 0;
|
||||||
|
m_meterProfileMax = 0;
|
||||||
|
m_meterProfileFrames = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void GraphEditorWidget::updateLayoutState()
|
void GraphEditorWidget::updateLayoutState()
|
||||||
|
|
@ -458,6 +641,28 @@ void GraphEditorWidget::updateLayoutState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GraphEditorWidget::reloadGraphFromController()
|
||||||
|
{
|
||||||
|
m_ignoreCreate.clear();
|
||||||
|
m_ignoreDelete.clear();
|
||||||
|
m_connectionToLinkId.clear();
|
||||||
|
m_linkIdToConnection.clear();
|
||||||
|
m_nodeLinkCounts.clear();
|
||||||
|
m_linksById.clear();
|
||||||
|
|
||||||
|
m_model->reset();
|
||||||
|
syncGraph();
|
||||||
|
|
||||||
|
double viewScale = 1.0;
|
||||||
|
QPointF viewCenter;
|
||||||
|
if (m_model->viewState(viewScale, viewCenter)) {
|
||||||
|
m_view->setupScale(viewScale);
|
||||||
|
m_view->centerOn(viewCenter);
|
||||||
|
} else {
|
||||||
|
m_view->zoomFitAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void GraphEditorWidget::refreshNodeMeter(uint32_t nodeId, const Potato::NodeInfo &node)
|
void GraphEditorWidget::refreshNodeMeter(uint32_t nodeId, const Potato::NodeInfo &node)
|
||||||
{
|
{
|
||||||
if (m_nodeMeterRows.contains(nodeId)) {
|
if (m_nodeMeterRows.contains(nodeId)) {
|
||||||
|
|
@ -616,3 +821,242 @@ bool GraphEditorWidget::eventFilter(QObject *object, QEvent *event)
|
||||||
|
|
||||||
return QWidget::eventFilter(object, 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 ClickSlider(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);
|
||||||
|
m_mixerLastState.insert(nodeId, NodeVolumeState{1.0f, false});
|
||||||
|
|
||||||
|
connect(fader, &QSlider::valueChanged, [this, nodeId](int value) {
|
||||||
|
if (!m_mixerMutes.contains(nodeId) || !m_mixerFaders.contains(nodeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const float volume = static_cast<float>(value) / 100.0f;
|
||||||
|
const bool userMute = m_mixerUserMute.value(nodeId, false);
|
||||||
|
if (m_mixerFaders[nodeId]->isSliderDown()) {
|
||||||
|
if (!m_mixerStartState.contains(nodeId)) {
|
||||||
|
const NodeVolumeState previous = m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute});
|
||||||
|
m_mixerStartState.insert(nodeId, previous);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const NodeVolumeState previous = m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute});
|
||||||
|
const NodeVolumeState next{volume, userMute};
|
||||||
|
pushVolumeCommand(nodeId, previous, next);
|
||||||
|
m_mixerLastState.insert(nodeId, next);
|
||||||
|
}
|
||||||
|
applySoloState();
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(fader, &QSlider::sliderPressed, [this, nodeId]() {
|
||||||
|
if (!m_mixerFaders.contains(nodeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const float volume = static_cast<float>(m_mixerFaders[nodeId]->value()) / 100.0f;
|
||||||
|
const bool userMute = m_mixerUserMute.value(nodeId, false);
|
||||||
|
const NodeVolumeState previous = m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute});
|
||||||
|
m_mixerStartState.insert(nodeId, previous);
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(fader, &QSlider::sliderReleased, [this, nodeId]() {
|
||||||
|
if (!m_mixerFaders.contains(nodeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const float volume = static_cast<float>(m_mixerFaders[nodeId]->value()) / 100.0f;
|
||||||
|
const bool userMute = m_mixerUserMute.value(nodeId, false);
|
||||||
|
const NodeVolumeState previous = m_mixerStartState.value(nodeId, m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute}));
|
||||||
|
const NodeVolumeState next{volume, userMute};
|
||||||
|
m_mixerStartState.remove(nodeId);
|
||||||
|
pushVolumeCommand(nodeId, previous, next);
|
||||||
|
m_mixerLastState.insert(nodeId, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(muteBtn, &QToolButton::toggled, [this, nodeId](bool checked) {
|
||||||
|
if (!m_mixerFaders.contains(nodeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const float volume = static_cast<float>(m_mixerFaders[nodeId]->value()) / 100.0f;
|
||||||
|
const bool previousMute = m_mixerUserMute.value(nodeId, false);
|
||||||
|
const NodeVolumeState previous{volume, previousMute};
|
||||||
|
const NodeVolumeState next{volume, checked};
|
||||||
|
m_mixerUserMute[nodeId] = checked;
|
||||||
|
applySoloState();
|
||||||
|
pushVolumeCommand(nodeId, previous, next);
|
||||||
|
m_mixerLastState.insert(nodeId, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
m_mixerLastState.insert(nodeId, state);
|
||||||
|
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_mixerStartState.remove(nodeId);
|
||||||
|
m_mixerLastState.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}, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GraphEditorWidget::applyVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool updateMixer)
|
||||||
|
{
|
||||||
|
Q_UNUSED(updateMixer)
|
||||||
|
m_ignoreVolumeUndo = true;
|
||||||
|
if (m_model) {
|
||||||
|
m_model->setNodeVolumeState(nodeId, state, false);
|
||||||
|
}
|
||||||
|
m_mixerUserMute[nodeId] = state.mute;
|
||||||
|
if (m_mixerFaders.contains(nodeId)) {
|
||||||
|
m_mixerFaders[nodeId]->blockSignals(true);
|
||||||
|
m_mixerFaders[nodeId]->setValue(static_cast<int>(state.volume * 100.0f));
|
||||||
|
m_mixerFaders[nodeId]->blockSignals(false);
|
||||||
|
}
|
||||||
|
if (m_mixerMutes.contains(nodeId)) {
|
||||||
|
m_mixerMutes[nodeId]->blockSignals(true);
|
||||||
|
m_mixerMutes[nodeId]->setChecked(state.mute);
|
||||||
|
m_mixerMutes[nodeId]->blockSignals(false);
|
||||||
|
}
|
||||||
|
m_mixerLastState.insert(nodeId, state);
|
||||||
|
m_mixerStartState.remove(nodeId);
|
||||||
|
applySoloState();
|
||||||
|
m_ignoreVolumeUndo = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GraphEditorWidget::pushVolumeCommand(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &next)
|
||||||
|
{
|
||||||
|
if (!m_scene || m_ignoreVolumeUndo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bool changedVolume = qAbs(previous.volume - next.volume) > 0.0001f;
|
||||||
|
if (!changedVolume && previous.mute == next.mute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_scene->undoStack().push(new VolumeChangeCommand(this, nodeId, previous, next));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
|
#include <QElapsedTimer>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
class AudioLevelMeter;
|
class AudioLevelMeter;
|
||||||
|
|
@ -16,12 +17,20 @@ class QLabel;
|
||||||
class QTimer;
|
class QTimer;
|
||||||
class QScrollArea;
|
class QScrollArea;
|
||||||
class QVBoxLayout;
|
class QVBoxLayout;
|
||||||
|
class QHBoxLayout;
|
||||||
|
class QSlider;
|
||||||
|
class QToolButton;
|
||||||
class QSplitter;
|
class QSplitter;
|
||||||
|
class QTabWidget;
|
||||||
|
class PresetManager;
|
||||||
|
class VolumeChangeCommand;
|
||||||
|
|
||||||
class GraphEditorWidget : public QWidget
|
class GraphEditorWidget : public QWidget
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
|
friend class VolumeChangeCommand;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent = nullptr);
|
explicit GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
|
@ -42,9 +51,16 @@ private:
|
||||||
void updateLayoutState();
|
void updateLayoutState();
|
||||||
void updateNodeMeterLabel(QLabel *label);
|
void updateNodeMeterLabel(QLabel *label);
|
||||||
void updateNodeMeterState(uint32_t nodeId, const Potato::NodeInfo &node);
|
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 applyVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool updateMixer);
|
||||||
|
void pushVolumeCommand(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &next);
|
||||||
void handleLinkRemoved(uint32_t linkId);
|
void handleLinkRemoved(uint32_t linkId);
|
||||||
bool isMeterNode(uint32_t nodeId) const;
|
bool isMeterNode(uint32_t nodeId) const;
|
||||||
int activeLinkCount(uint32_t nodeId) const;
|
int activeLinkCount(uint32_t nodeId) const;
|
||||||
|
void reloadGraphFromController();
|
||||||
QString connectionKey(const QtNodes::ConnectionId &connectionId) const;
|
QString connectionKey(const QtNodes::ConnectionId &connectionId) const;
|
||||||
bool eventFilter(QObject *object, QEvent *event) override;
|
bool eventFilter(QObject *object, QEvent *event) override;
|
||||||
|
|
||||||
|
|
@ -54,11 +70,27 @@ private:
|
||||||
QtNodes::GraphicsView *m_view = nullptr;
|
QtNodes::GraphicsView *m_view = nullptr;
|
||||||
QSplitter *m_splitter = 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;
|
||||||
|
QMap<uint32_t, NodeVolumeState> m_mixerStartState;
|
||||||
|
QMap<uint32_t, NodeVolumeState> m_mixerLastState;
|
||||||
|
QSet<uint32_t> m_mixerSoloNodes;
|
||||||
|
|
||||||
QSet<QString> m_ignoreCreate;
|
QSet<QString> m_ignoreCreate;
|
||||||
QSet<QString> m_ignoreDelete;
|
QSet<QString> m_ignoreDelete;
|
||||||
QSet<uint32_t> m_ignoreLinkRemoved;
|
QSet<uint32_t> m_ignoreLinkRemoved;
|
||||||
QMap<QString, uint32_t> m_connectionToLinkId;
|
QMap<QString, uint32_t> m_connectionToLinkId;
|
||||||
QMap<uint32_t, QString> m_linkIdToConnection;
|
QMap<uint32_t, QString> m_linkIdToConnection;
|
||||||
|
int m_virtualSinkCount = 0;
|
||||||
|
int m_virtualSourceCount = 0;
|
||||||
AudioLevelMeter *m_meter = nullptr;
|
AudioLevelMeter *m_meter = nullptr;
|
||||||
QTimer *m_meterTimer = nullptr;
|
QTimer *m_meterTimer = nullptr;
|
||||||
QScrollArea *m_meterScroll = nullptr;
|
QScrollArea *m_meterScroll = nullptr;
|
||||||
|
|
@ -69,4 +101,11 @@ private:
|
||||||
QMap<uint32_t, QLabel*> m_nodeMeterLabels;
|
QMap<uint32_t, QLabel*> m_nodeMeterLabels;
|
||||||
QMap<uint32_t, int> m_nodeLinkCounts;
|
QMap<uint32_t, int> m_nodeLinkCounts;
|
||||||
QMap<uint32_t, Potato::LinkInfo> m_linksById;
|
QMap<uint32_t, Potato::LinkInfo> m_linksById;
|
||||||
|
QElapsedTimer m_meterProfileTimer;
|
||||||
|
bool m_meterProfileReady = false;
|
||||||
|
qint64 m_meterProfileNanos = 0;
|
||||||
|
qint64 m_meterProfileMax = 0;
|
||||||
|
int m_meterProfileFrames = 0;
|
||||||
|
PresetManager *m_presetManager = nullptr;
|
||||||
|
bool m_ignoreVolumeUndo = false;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,18 @@
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QSlider>
|
||||||
|
#include "gui/ClickSlider.h"
|
||||||
|
#include <QSizePolicy>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
#include <QtNodes/NodeStyle>
|
#include <QtNodes/NodeStyle>
|
||||||
#include <QtNodes/StyleCollection>
|
#include <QtNodes/StyleCollection>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
|
@ -108,6 +115,103 @@ PipeWireGraphModel::PipeWireGraphModel(Potato::PipeWireController *controller, Q
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const
|
||||||
|
{
|
||||||
|
auto it = m_nodeWidgets.find(nodeId);
|
||||||
|
if (it != m_nodeWidgets.end()) {
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t pipewireId = 0;
|
||||||
|
auto nodeIt = m_nodes.find(nodeId);
|
||||||
|
if (nodeIt != m_nodes.end()) {
|
||||||
|
pipewireId = nodeIt->second.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto *widget = new QWidget();
|
||||||
|
auto *layout = new QHBoxLayout(widget);
|
||||||
|
layout->setContentsMargins(6, 2, 6, 2);
|
||||||
|
layout->setSpacing(6);
|
||||||
|
|
||||||
|
auto *muteButton = new QToolButton(widget);
|
||||||
|
muteButton->setText("M");
|
||||||
|
muteButton->setCheckable(true);
|
||||||
|
muteButton->setFixedSize(20, 20);
|
||||||
|
muteButton->setToolTip(QString("Mute"));
|
||||||
|
|
||||||
|
auto *slider = new ClickSlider(Qt::Horizontal, widget);
|
||||||
|
slider->setRange(0, 100);
|
||||||
|
slider->setValue(100);
|
||||||
|
slider->setFixedHeight(18);
|
||||||
|
slider->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
||||||
|
slider->setToolTip(QString("Volume"));
|
||||||
|
|
||||||
|
if (pipewireId != 0 && m_nodeVolumeState.contains(pipewireId)) {
|
||||||
|
const NodeVolumeState state = m_nodeVolumeState.value(pipewireId);
|
||||||
|
slider->setValue(static_cast<int>(state.volume * 100.0f));
|
||||||
|
muteButton->setChecked(state.mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto applyVolume = [this, pipewireId, slider, muteButton]() {
|
||||||
|
if (!m_controller || pipewireId == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const float volume = static_cast<float>(slider->value()) / 100.0f;
|
||||||
|
const NodeVolumeState next{volume, muteButton->isChecked()};
|
||||||
|
const NodeVolumeState previous = m_nodeVolumeState.value(pipewireId, next);
|
||||||
|
m_controller->setNodeVolume(pipewireId, volume, next.mute);
|
||||||
|
auto *self = const_cast<PipeWireGraphModel*>(this);
|
||||||
|
self->setNodeVolumeState(pipewireId, next, false);
|
||||||
|
if (!slider->isSliderDown() && !m_inlineStartState.contains(pipewireId)) {
|
||||||
|
self->emitNodeVolumeChanged(pipewireId, previous, next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QObject::connect(slider, &QSlider::valueChanged, widget, [applyVolume](int) { applyVolume(); });
|
||||||
|
QObject::connect(muteButton, &QToolButton::toggled, widget, [applyVolume](bool) { applyVolume(); });
|
||||||
|
QObject::connect(slider, &QSlider::sliderPressed, widget, [this, pipewireId, slider, muteButton]() {
|
||||||
|
if (pipewireId == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bool ok = false;
|
||||||
|
const int pressValue = slider->property("pressValue").toInt(&ok);
|
||||||
|
const float volume = ok ? (static_cast<float>(pressValue) / 100.0f)
|
||||||
|
: static_cast<float>(slider->value()) / 100.0f;
|
||||||
|
const bool mute = muteButton->isChecked();
|
||||||
|
m_inlineStartState.insert(pipewireId, NodeVolumeState{volume, mute});
|
||||||
|
});
|
||||||
|
QObject::connect(slider, &QSlider::sliderReleased, widget, [this, pipewireId, slider, muteButton]() {
|
||||||
|
if (pipewireId == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const NodeVolumeState previous = m_inlineStartState.value(pipewireId, m_nodeVolumeState.value(pipewireId));
|
||||||
|
m_inlineStartState.remove(pipewireId);
|
||||||
|
const float volume = static_cast<float>(slider->value()) / 100.0f;
|
||||||
|
const NodeVolumeState next{volume, muteButton->isChecked()};
|
||||||
|
auto *self = const_cast<PipeWireGraphModel*>(this);
|
||||||
|
self->emitNodeVolumeChanged(pipewireId, previous, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
layout->addWidget(muteButton);
|
||||||
|
layout->addWidget(slider);
|
||||||
|
|
||||||
|
widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
|
||||||
|
widget->setFixedHeight(26);
|
||||||
|
widget->setStyleSheet(
|
||||||
|
"QToolButton { background: #2b2f38; color: #dbe2ee; border: 1px solid #39404c; border-radius: 4px; font-weight: 700; }"
|
||||||
|
"QToolButton:checked { background: #3c4350; color: #ffcf7a; }"
|
||||||
|
"QSlider::groove:horizontal { height: 4px; background: #2b2f38; border-radius: 2px; }"
|
||||||
|
"QSlider::sub-page:horizontal { background: #5fcf8d; border-radius: 2px; }"
|
||||||
|
"QSlider::add-page:horizontal { background: #2b2f38; border-radius: 2px; }"
|
||||||
|
"QSlider::handle:horizontal { width: 10px; margin: -6px 0; background: #dbe2ee; border-radius: 5px; }"
|
||||||
|
);
|
||||||
|
|
||||||
|
widget->adjustSize();
|
||||||
|
|
||||||
|
m_nodeWidgets.emplace(nodeId, widget);
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
|
||||||
QtNodes::NodeId PipeWireGraphModel::newNodeId()
|
QtNodes::NodeId PipeWireGraphModel::newNodeId()
|
||||||
{
|
{
|
||||||
return m_nextNodeId++;
|
return m_nextNodeId++;
|
||||||
|
|
@ -210,6 +314,23 @@ bool PipeWireGraphModel::connectionPossible(QtNodes::ConnectionId const connecti
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auto &outPort = outInfo.outputPorts.at(connectionId.outPortIndex);
|
||||||
|
const auto &inPort = inInfo.inputPorts.at(connectionId.inPortIndex);
|
||||||
|
if (outPort.direction != SPA_DIRECTION_OUTPUT || inPort.direction != SPA_DIRECTION_INPUT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto isAudioClass = [](Potato::MediaClass mediaClass) {
|
||||||
|
return mediaClass == Potato::MediaClass::AudioSink
|
||||||
|
|| mediaClass == Potato::MediaClass::AudioSource
|
||||||
|
|| mediaClass == Potato::MediaClass::AudioDuplex
|
||||||
|
|| mediaClass == Potato::MediaClass::Stream;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAudioClass(outInfo.mediaClass) || !isAudioClass(inInfo.mediaClass)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,6 +378,10 @@ QVariant PipeWireGraphModel::nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole
|
||||||
}
|
}
|
||||||
case QtNodes::NodeRole::Size:
|
case QtNodes::NodeRole::Size:
|
||||||
{
|
{
|
||||||
|
auto sizeIt = m_nodeSizes.find(nodeId);
|
||||||
|
if (sizeIt != m_nodeSizes.end()) {
|
||||||
|
return sizeIt->second;
|
||||||
|
}
|
||||||
const int maxPorts = std::max(info.inputPorts.size(), info.outputPorts.size());
|
const int maxPorts = std::max(info.inputPorts.size(), info.outputPorts.size());
|
||||||
const int baseHeight = 50;
|
const int baseHeight = 50;
|
||||||
const int perPortHeight = 28;
|
const int perPortHeight = 28;
|
||||||
|
|
@ -274,6 +399,8 @@ QVariant PipeWireGraphModel::nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole
|
||||||
return QString("PipeWire");
|
return QString("PipeWire");
|
||||||
case QtNodes::NodeRole::Style:
|
case QtNodes::NodeRole::Style:
|
||||||
return nodeStyleVariant(info);
|
return nodeStyleVariant(info);
|
||||||
|
case QtNodes::NodeRole::Widget:
|
||||||
|
return QVariant::fromValue(nodeWidget(nodeId));
|
||||||
default:
|
default:
|
||||||
return QVariant();
|
return QVariant();
|
||||||
}
|
}
|
||||||
|
|
@ -293,6 +420,11 @@ bool PipeWireGraphModel::setNodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole r
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role == QtNodes::NodeRole::Size) {
|
||||||
|
m_nodeSizes[nodeId] = value.toSize();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,9 +465,6 @@ QVariant PipeWireGraphModel::portData(QtNodes::NodeId nodeId,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role == QtNodes::PortRole::ConnectionPolicyRole) {
|
if (role == QtNodes::PortRole::ConnectionPolicyRole) {
|
||||||
if (portType == QtNodes::PortType::In) {
|
|
||||||
return QVariant::fromValue(QtNodes::ConnectionPolicy::One);
|
|
||||||
}
|
|
||||||
return QVariant::fromValue(QtNodes::ConnectionPolicy::Many);
|
return QVariant::fromValue(QtNodes::ConnectionPolicy::Many);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,6 +508,8 @@ bool PipeWireGraphModel::deleteNode(QtNodes::NodeId const nodeId)
|
||||||
|
|
||||||
m_nodes.erase(nodeId);
|
m_nodes.erase(nodeId);
|
||||||
m_positions.erase(nodeId);
|
m_positions.erase(nodeId);
|
||||||
|
m_nodeSizes.erase(nodeId);
|
||||||
|
m_nodeWidgets.erase(nodeId);
|
||||||
Q_EMIT nodeDeleted(nodeId);
|
Q_EMIT nodeDeleted(nodeId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -476,6 +607,7 @@ bool PipeWireGraphModel::updatePipeWireNode(const Potato::NodeInfo &node)
|
||||||
|
|
||||||
const QtNodes::NodeId nodeId = it->second;
|
const QtNodes::NodeId nodeId = it->second;
|
||||||
m_nodes[nodeId] = node;
|
m_nodes[nodeId] = node;
|
||||||
|
m_nodeSizes.erase(nodeId);
|
||||||
Q_EMIT nodeUpdated(nodeId);
|
Q_EMIT nodeUpdated(nodeId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -503,6 +635,8 @@ void PipeWireGraphModel::reset()
|
||||||
m_nodes.clear();
|
m_nodes.clear();
|
||||||
m_pwToNode.clear();
|
m_pwToNode.clear();
|
||||||
m_positions.clear();
|
m_positions.clear();
|
||||||
|
m_nodeSizes.clear();
|
||||||
|
m_nodeWidgets.clear();
|
||||||
m_nextNodeId = 1;
|
m_nextNodeId = 1;
|
||||||
Q_EMIT modelReset();
|
Q_EMIT modelReset();
|
||||||
}
|
}
|
||||||
|
|
@ -597,6 +731,168 @@ void PipeWireGraphModel::loadLayout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QJsonObject PipeWireGraphModel::layoutJson() const
|
||||||
|
{
|
||||||
|
QJsonObject root;
|
||||||
|
QJsonArray nodes;
|
||||||
|
for (auto it = m_layoutByStableId.cbegin(); it != m_layoutByStableId.cend(); ++it) {
|
||||||
|
QJsonObject item;
|
||||||
|
item["id"] = it.key();
|
||||||
|
item["x"] = it.value().x();
|
||||||
|
item["y"] = it.value().y();
|
||||||
|
nodes.append(item);
|
||||||
|
}
|
||||||
|
root["nodes"] = nodes;
|
||||||
|
|
||||||
|
QJsonObject view;
|
||||||
|
view["scale"] = m_viewScale;
|
||||||
|
view["center_x"] = m_viewCenter.x();
|
||||||
|
view["center_y"] = m_viewCenter.y();
|
||||||
|
root["view"] = view;
|
||||||
|
|
||||||
|
if (m_hasSplitterSizes && !m_splitterSizes.isEmpty()) {
|
||||||
|
QJsonArray splitter;
|
||||||
|
for (const auto size : m_splitterSizes) {
|
||||||
|
splitter.append(size);
|
||||||
|
}
|
||||||
|
root["splitter"] = splitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PipeWireGraphModel::applyLayoutJson(const QJsonObject &root)
|
||||||
|
{
|
||||||
|
m_layoutByStableId.clear();
|
||||||
|
m_hasViewState = false;
|
||||||
|
m_hasSplitterSizes = false;
|
||||||
|
m_splitterSizes.clear();
|
||||||
|
|
||||||
|
const QJsonArray nodes = root.value("nodes").toArray();
|
||||||
|
applyLayoutData(nodes);
|
||||||
|
|
||||||
|
const QJsonObject view = root.value("view").toObject();
|
||||||
|
if (!view.isEmpty()) {
|
||||||
|
m_viewScale = view.value("scale").toDouble(1.0);
|
||||||
|
const double x = view.value("center_x").toDouble(0.0);
|
||||||
|
const double y = view.value("center_y").toDouble(0.0);
|
||||||
|
m_viewCenter = QPointF(x, y);
|
||||||
|
m_hasViewState = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonArray splitter = root.value("splitter").toArray();
|
||||||
|
if (!splitter.isEmpty()) {
|
||||||
|
QList<int> sizes;
|
||||||
|
sizes.reserve(splitter.size());
|
||||||
|
for (const auto &value : splitter) {
|
||||||
|
sizes.append(value.toInt());
|
||||||
|
}
|
||||||
|
if (!sizes.isEmpty()) {
|
||||||
|
m_splitterSizes = sizes;
|
||||||
|
m_hasSplitterSizes = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto &entry : m_nodes) {
|
||||||
|
const QtNodes::NodeId nodeId = entry.first;
|
||||||
|
const Potato::NodeInfo &info = entry.second;
|
||||||
|
if (!info.stableId.isEmpty() && m_layoutByStableId.contains(info.stableId)) {
|
||||||
|
const QPointF position = m_layoutByStableId.value(info.stableId);
|
||||||
|
m_positions[nodeId] = position;
|
||||||
|
Q_EMIT nodePositionUpdated(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<QString, NodeVolumeState> PipeWireGraphModel::volumeStates() const
|
||||||
|
{
|
||||||
|
QHash<QString, NodeVolumeState> result;
|
||||||
|
for (const auto &entry : m_nodes) {
|
||||||
|
const Potato::NodeInfo &info = entry.second;
|
||||||
|
if (info.stableId.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!m_nodeVolumeState.contains(info.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.insert(info.stableId, m_nodeVolumeState.value(info.id));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PipeWireGraphModel::applyVolumeStates(const QHash<QString, NodeVolumeState> &states)
|
||||||
|
{
|
||||||
|
if (!m_controller) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QVector<Potato::NodeInfo> nodes = m_controller->nodes();
|
||||||
|
for (const auto &node : nodes) {
|
||||||
|
if (node.stableId.isEmpty() || !states.contains(node.stableId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const NodeVolumeState state = states.value(node.stableId);
|
||||||
|
setNodeVolumeState(node.id, state, false);
|
||||||
|
m_controller->setNodeVolume(node.id, state.volume, state.mute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PipeWireGraphModel::emitNodeVolumeChanged(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState ¤t)
|
||||||
|
{
|
||||||
|
const bool changedVolume = qAbs(previous.volume - current.volume) > 0.0001f;
|
||||||
|
if (!changedVolume && previous.mute == current.mute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Q_EMIT nodeVolumeChanged(nodeId, previous, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PipeWireGraphModel::setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool notify)
|
||||||
|
{
|
||||||
|
const NodeVolumeState previous = m_nodeVolumeState.value(nodeId, NodeVolumeState{});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notify) {
|
||||||
|
const bool changedVolume = std::abs(previous.volume - state.volume) > 0.0001f;
|
||||||
|
if (changedVolume || previous.mute != state.mute) {
|
||||||
|
Q_EMIT nodeVolumeChanged(nodeId, previous, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
void PipeWireGraphModel::saveLayout() const
|
||||||
{
|
{
|
||||||
const QString path = layoutFilePath();
|
const QString path = layoutFilePath();
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
|
class QWidget;
|
||||||
|
|
||||||
|
struct NodeVolumeState {
|
||||||
|
float volume = 1.0f;
|
||||||
|
bool mute = false;
|
||||||
|
};
|
||||||
|
|
||||||
class PipeWireGraphModel : public QtNodes::AbstractGraphModel
|
class PipeWireGraphModel : public QtNodes::AbstractGraphModel
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
@ -70,8 +77,19 @@ public:
|
||||||
bool viewState(double &scale, QPointF ¢er) const;
|
bool viewState(double &scale, QPointF ¢er) const;
|
||||||
void setSplitterSizes(const QList<int> &sizes);
|
void setSplitterSizes(const QList<int> &sizes);
|
||||||
bool splitterSizes(QList<int> &sizes) const;
|
bool splitterSizes(QList<int> &sizes) const;
|
||||||
|
QJsonObject layoutJson() const;
|
||||||
|
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 notify = true);
|
||||||
|
bool nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void nodeVolumeChanged(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState ¤t);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
QWidget *nodeWidget(QtNodes::NodeId nodeId) const;
|
||||||
|
void emitNodeVolumeChanged(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState ¤t);
|
||||||
QtNodes::ConnectionId connectionFromPipeWire(const Potato::LinkInfo &link, bool *ok) const;
|
QtNodes::ConnectionId connectionFromPipeWire(const Potato::LinkInfo &link, bool *ok) const;
|
||||||
bool findPortIndex(const Potato::NodeInfo &node, uint32_t portId, QtNodes::PortType type, QtNodes::PortIndex &index) const;
|
bool findPortIndex(const Potato::NodeInfo &node, uint32_t portId, QtNodes::PortType type, QtNodes::PortIndex &index) const;
|
||||||
QString portLabel(const Potato::PortInfo &port) const;
|
QString portLabel(const Potato::PortInfo &port) const;
|
||||||
|
|
@ -95,4 +113,8 @@ private:
|
||||||
bool m_hasViewState = false;
|
bool m_hasViewState = false;
|
||||||
QList<int> m_splitterSizes;
|
QList<int> m_splitterSizes;
|
||||||
bool m_hasSplitterSizes = false;
|
bool m_hasSplitterSizes = false;
|
||||||
|
mutable std::unordered_map<QtNodes::NodeId, QWidget*> m_nodeWidgets;
|
||||||
|
std::unordered_map<QtNodes::NodeId, QSize> m_nodeSizes;
|
||||||
|
mutable QHash<uint32_t, NodeVolumeState> m_nodeVolumeState;
|
||||||
|
mutable QHash<uint32_t, NodeVolumeState> m_inlineStartState;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QElapsedTimer>
|
#include <QElapsedTimer>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
@ -13,14 +14,119 @@
|
||||||
#include <pipewire/keys.h>
|
#include <pipewire/keys.h>
|
||||||
#include <pipewire/properties.h>
|
#include <pipewire/properties.h>
|
||||||
#include <pipewire/stream.h>
|
#include <pipewire/stream.h>
|
||||||
|
#include <pipewire/node.h>
|
||||||
#include <spa/param/props.h>
|
#include <spa/param/props.h>
|
||||||
#include <spa/param/audio/format-utils.h>
|
#include <spa/param/audio/format-utils.h>
|
||||||
#include <spa/param/audio/raw.h>
|
#include <spa/param/audio/raw.h>
|
||||||
#include <spa/utils/dict.h>
|
#include <spa/utils/dict.h>
|
||||||
|
#include <spa/utils/defs.h>
|
||||||
#include <spa/utils/type-info.h>
|
#include <spa/utils/type-info.h>
|
||||||
|
|
||||||
namespace Potato {
|
namespace Potato {
|
||||||
|
|
||||||
|
static constexpr uint32_t kMeterRingCapacityBytes = 4096;
|
||||||
|
|
||||||
|
static void writeRingValue(spa_ringbuffer *ring, std::vector<uint8_t> &buffer, float value)
|
||||||
|
{
|
||||||
|
if (!ring || buffer.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t index = 0;
|
||||||
|
const uint32_t avail = spa_ringbuffer_get_write_index(ring, &index);
|
||||||
|
if (avail < sizeof(float)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t size = static_cast<uint32_t>(buffer.size());
|
||||||
|
uint32_t offset = index & (size - 1);
|
||||||
|
if (offset + sizeof(float) <= size) {
|
||||||
|
std::memcpy(buffer.data() + offset, &value, sizeof(float));
|
||||||
|
} else {
|
||||||
|
const uint32_t first = size - offset;
|
||||||
|
std::memcpy(buffer.data() + offset, &value, first);
|
||||||
|
std::memcpy(buffer.data(), reinterpret_cast<const uint8_t*>(&value) + first, sizeof(float) - first);
|
||||||
|
}
|
||||||
|
spa_ringbuffer_write_update(ring, index + sizeof(float));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool readRingLatest(spa_ringbuffer *ring, std::vector<uint8_t> &buffer, float &value)
|
||||||
|
{
|
||||||
|
if (!ring || buffer.empty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t index = 0;
|
||||||
|
const uint32_t avail = spa_ringbuffer_get_read_index(ring, &index);
|
||||||
|
if (avail < sizeof(float)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t size = static_cast<uint32_t>(buffer.size());
|
||||||
|
const uint32_t latestIndex = index + (avail - sizeof(float));
|
||||||
|
uint32_t offset = latestIndex & (size - 1);
|
||||||
|
if (offset + sizeof(float) <= size) {
|
||||||
|
std::memcpy(&value, buffer.data() + offset, sizeof(float));
|
||||||
|
} else {
|
||||||
|
const uint32_t first = size - offset;
|
||||||
|
std::memcpy(&value, buffer.data() + offset, first);
|
||||||
|
std::memcpy(reinterpret_cast<uint8_t*>(&value) + first, buffer.data(), sizeof(float) - first);
|
||||||
|
}
|
||||||
|
|
||||||
|
spa_ringbuffer_read_update(ring, index + avail);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PipeWireController::createVirtualDevice(const QString &name,
|
||||||
|
const QString &description,
|
||||||
|
const char *factoryName,
|
||||||
|
const char *mediaClass,
|
||||||
|
int channels,
|
||||||
|
int rate)
|
||||||
|
{
|
||||||
|
if (!m_threadLoop || !m_core || name.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
channels = channels > 0 ? channels : 2;
|
||||||
|
rate = rate > 0 ? rate : 48000;
|
||||||
|
|
||||||
|
const QByteArray nameBytes = name.toUtf8();
|
||||||
|
const QByteArray descBytes = description.isEmpty() ? nameBytes : description.toUtf8();
|
||||||
|
const QByteArray channelsBytes = QByteArray::number(channels);
|
||||||
|
const QByteArray rateBytes = QByteArray::number(rate);
|
||||||
|
|
||||||
|
struct spa_dict_item items[] = {
|
||||||
|
{ PW_KEY_FACTORY_NAME, factoryName },
|
||||||
|
{ PW_KEY_NODE_NAME, nameBytes.constData() },
|
||||||
|
{ PW_KEY_NODE_DESCRIPTION, descBytes.constData() },
|
||||||
|
{ PW_KEY_MEDIA_CLASS, mediaClass },
|
||||||
|
{ PW_KEY_AUDIO_CHANNELS, channelsBytes.constData() },
|
||||||
|
{ PW_KEY_AUDIO_RATE, rateBytes.constData() },
|
||||||
|
{ "object.linger", "true" },
|
||||||
|
{ PW_KEY_APP_NAME, "Potato-Manager" }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct spa_dict dict = SPA_DICT_INIT(items, SPA_N_ELEMENTS(items));
|
||||||
|
|
||||||
|
lock();
|
||||||
|
auto *proxy = static_cast<struct pw_proxy*>(pw_core_create_object(
|
||||||
|
m_core,
|
||||||
|
"adapter",
|
||||||
|
PW_TYPE_INTERFACE_Node,
|
||||||
|
PW_VERSION_NODE,
|
||||||
|
&dict,
|
||||||
|
0));
|
||||||
|
unlock();
|
||||||
|
|
||||||
|
if (!proxy) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_virtualDevices.push_back(proxy);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static QString toQString(const char *value)
|
static QString toQString(const char *value)
|
||||||
{
|
{
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|
@ -63,13 +169,22 @@ void registryEventGlobalRemove(void *data, uint32_t id)
|
||||||
self->m_ports.remove(id);
|
self->m_ports.remove(id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool linkRemoved = false;
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&self->m_nodesMutex);
|
||||||
if (self->m_links.contains(id)) {
|
if (self->m_links.contains(id)) {
|
||||||
self->m_links.remove(id);
|
self->m_links.remove(id);
|
||||||
emit self->linkRemoved(id);
|
linkRemoved = true;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (linkRemoved) {
|
||||||
|
emit self->linkRemoved(id);
|
||||||
|
self->handleLinkRemoval(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void coreEventDone(void *data, uint32_t id, int seq)
|
void coreEventDone(void *data, uint32_t id, int seq)
|
||||||
|
|
@ -150,6 +265,9 @@ void meterProcess(void *data)
|
||||||
}
|
}
|
||||||
|
|
||||||
self->m_meterPeak.store(peak, std::memory_order_relaxed);
|
self->m_meterPeak.store(peak, std::memory_order_relaxed);
|
||||||
|
if (self->m_meterRingReady.load(std::memory_order_relaxed)) {
|
||||||
|
writeRingValue(&self->m_meterRing, self->m_meterRingData, peak);
|
||||||
|
}
|
||||||
pw_stream_queue_buffer(self->m_meterStream, buf);
|
pw_stream_queue_buffer(self->m_meterStream, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,6 +335,9 @@ PipeWireController::PipeWireController(QObject *parent)
|
||||||
{
|
{
|
||||||
m_registryListener = new spa_hook;
|
m_registryListener = new spa_hook;
|
||||||
m_coreListener = new spa_hook;
|
m_coreListener = new spa_hook;
|
||||||
|
m_meterRingData.resize(kMeterRingCapacityBytes);
|
||||||
|
spa_ringbuffer_init(&m_meterRing);
|
||||||
|
m_meterRingReady.store(true, std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
PipeWireController::~PipeWireController()
|
PipeWireController::~PipeWireController()
|
||||||
|
|
@ -322,6 +443,13 @@ void PipeWireController::shutdown()
|
||||||
m_nodeMeters.clear();
|
m_nodeMeters.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (auto *proxy : m_virtualDevices) {
|
||||||
|
if (proxy) {
|
||||||
|
pw_proxy_destroy(proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m_virtualDevices.clear();
|
||||||
|
|
||||||
if (m_core) {
|
if (m_core) {
|
||||||
pw_core_disconnect(m_core);
|
pw_core_disconnect(m_core);
|
||||||
m_core = nullptr;
|
m_core = nullptr;
|
||||||
|
|
@ -341,6 +469,9 @@ void PipeWireController::shutdown()
|
||||||
|
|
||||||
pw_deinit();
|
pw_deinit();
|
||||||
|
|
||||||
|
m_meterRingReady.store(false, std::memory_order_relaxed);
|
||||||
|
m_meterRingData.clear();
|
||||||
|
|
||||||
m_initialized.storeRelaxed(false);
|
m_initialized.storeRelaxed(false);
|
||||||
m_connected.storeRelaxed(false);
|
m_connected.storeRelaxed(false);
|
||||||
|
|
||||||
|
|
@ -372,7 +503,58 @@ QVector<LinkInfo> PipeWireController::links() const
|
||||||
|
|
||||||
float PipeWireController::meterPeak() const
|
float PipeWireController::meterPeak() const
|
||||||
{
|
{
|
||||||
return m_meterPeak.load(std::memory_order_relaxed);
|
float peak = m_meterPeak.load(std::memory_order_relaxed);
|
||||||
|
if (m_meterRingReady.load(std::memory_order_relaxed)) {
|
||||||
|
float ringPeak = 0.0f;
|
||||||
|
if (readRingLatest(&m_meterRing, m_meterRingData, ringPeak)) {
|
||||||
|
peak = ringPeak;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peak;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PipeWireController::setNodeVolume(uint32_t nodeId, float volume, bool mute)
|
||||||
|
{
|
||||||
|
if (!m_threadLoop || !m_core || !m_registry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeId == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
volume = std::clamp(volume, 0.0f, 1.0f);
|
||||||
|
|
||||||
|
lock();
|
||||||
|
auto *node = static_cast<struct pw_node*>(
|
||||||
|
pw_registry_bind(m_registry, nodeId, PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0));
|
||||||
|
if (!node) {
|
||||||
|
unlock();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t buffer[128];
|
||||||
|
spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
|
||||||
|
auto *param = reinterpret_cast<const struct spa_pod*>(spa_pod_builder_add_object(
|
||||||
|
&builder,
|
||||||
|
SPA_TYPE_OBJECT_Props, SPA_PARAM_Props,
|
||||||
|
SPA_PROP_volume, SPA_POD_Float(volume),
|
||||||
|
SPA_PROP_mute, SPA_POD_Bool(mute)));
|
||||||
|
|
||||||
|
pw_node_set_param(node, SPA_PARAM_Props, 0, param);
|
||||||
|
pw_proxy_destroy(reinterpret_cast<struct pw_proxy*>(node));
|
||||||
|
unlock();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PipeWireController::createVirtualSink(const QString &name, const QString &description, int channels, int rate)
|
||||||
|
{
|
||||||
|
return createVirtualDevice(name, description, "support.null-audio-sink", "Audio/Sink", channels, rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PipeWireController::createVirtualSource(const QString &name, const QString &description, int channels, int rate)
|
||||||
|
{
|
||||||
|
return createVirtualDevice(name, description, "support.null-audio-sink", "Audio/Source", channels, rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
float PipeWireController::nodeMeterPeak(uint32_t nodeId) const
|
float PipeWireController::nodeMeterPeak(uint32_t nodeId) const
|
||||||
|
|
@ -596,6 +778,11 @@ bool PipeWireController::destroyLink(uint32_t linkId)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
linkInfo = m_links.value(linkId);
|
linkInfo = m_links.value(linkId);
|
||||||
|
m_userRemovedLinks.insert(linkId);
|
||||||
|
if (m_linkIntentKeys.contains(linkId)) {
|
||||||
|
const QString key = m_linkIntentKeys.take(linkId);
|
||||||
|
m_linkIntents.remove(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lock();
|
lock();
|
||||||
|
|
@ -609,21 +796,156 @@ bool PipeWireController::destroyLink(uint32_t linkId)
|
||||||
|
|
||||||
unlock();
|
unlock();
|
||||||
|
|
||||||
QElapsedTimer timer;
|
qInfo() << "Link destroy requested:" << linkId;
|
||||||
timer.start();
|
|
||||||
while (timer.elapsed() < 2000) {
|
|
||||||
{
|
|
||||||
QMutexLocker lock(&m_nodesMutex);
|
|
||||||
if (!m_links.contains(linkId)) {
|
|
||||||
qInfo() << "Link destroyed:" << linkId;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
QThread::msleep(10);
|
void PipeWireController::rememberLinkIntent(const LinkInfo &link)
|
||||||
|
{
|
||||||
|
QString key;
|
||||||
|
if (!buildLinkIntentKey(link, key)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
qWarning() << "Link destroy requested but ID still present" << linkId;
|
QMutexLocker lock(&m_nodesMutex);
|
||||||
|
m_linkIntents.insert(key);
|
||||||
|
m_linkIntentKeys.insert(link.id, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PipeWireController::handleLinkRemoval(uint32_t linkId)
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&m_nodesMutex);
|
||||||
|
if (m_userRemovedLinks.contains(linkId)) {
|
||||||
|
m_userRemovedLinks.remove(linkId);
|
||||||
|
}
|
||||||
|
if (m_linkIntentKeys.contains(linkId)) {
|
||||||
|
m_linkIntentKeys.remove(linkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PipeWireController::tryRestoreLinks()
|
||||||
|
{
|
||||||
|
QList<QString> intents;
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&m_nodesMutex);
|
||||||
|
intents = m_linkIntents.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto &key : intents) {
|
||||||
|
uint32_t outNodeId = 0;
|
||||||
|
uint32_t outPortId = 0;
|
||||||
|
uint32_t inNodeId = 0;
|
||||||
|
uint32_t inPortId = 0;
|
||||||
|
if (!resolveLinkIntentKey(key, outNodeId, outPortId, inNodeId, inPortId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
createLink(outNodeId, outPortId, inNodeId, inPortId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PipeWireController::updateLinkIntentsForNode(uint32_t nodeId)
|
||||||
|
{
|
||||||
|
QVector<LinkInfo> links;
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&m_nodesMutex);
|
||||||
|
for (auto it = m_links.cbegin(); it != m_links.cend(); ++it) {
|
||||||
|
const LinkInfo &link = it.value();
|
||||||
|
if (link.outputNodeId == nodeId || link.inputNodeId == nodeId) {
|
||||||
|
links.append(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto &link : links) {
|
||||||
|
rememberLinkIntent(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isMeterNodeName(const QString &name)
|
||||||
|
{
|
||||||
|
return name.startsWith("Potato-Meter") || name.startsWith("Potato-Node-Meter");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PipeWireController::buildLinkIntentKey(const LinkInfo &link, QString &key) const
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&m_nodesMutex);
|
||||||
|
if (!m_nodes.contains(link.outputNodeId) || !m_nodes.contains(link.inputNodeId)) {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeInfo &outNode = m_nodes.value(link.outputNodeId);
|
||||||
|
const NodeInfo &inNode = m_nodes.value(link.inputNodeId);
|
||||||
|
if (isMeterNodeName(outNode.name) || isMeterNodeName(inNode.name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString outPortName;
|
||||||
|
QString inPortName;
|
||||||
|
for (const auto &port : outNode.outputPorts) {
|
||||||
|
if (port.id == link.outputPortId) {
|
||||||
|
outPortName = port.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto &port : inNode.inputPorts) {
|
||||||
|
if (port.id == link.inputPortId) {
|
||||||
|
inPortName = port.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (outPortName.isEmpty() || inPortName.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (outNode.stableId.isEmpty() || inNode.stableId.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
key = QString("%1||%2>>%3||%4").arg(outNode.stableId, outPortName, inNode.stableId, inPortName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PipeWireController::resolveLinkIntentKey(const QString &key, uint32_t &outNodeId, uint32_t &outPortId,
|
||||||
|
uint32_t &inNodeId, uint32_t &inPortId) const
|
||||||
|
{
|
||||||
|
const QStringList halves = key.split(">>");
|
||||||
|
if (halves.size() != 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QStringList outParts = halves.at(0).split("||");
|
||||||
|
const QStringList inParts = halves.at(1).split("||");
|
||||||
|
if (outParts.size() != 2 || inParts.size() != 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString outStableId = outParts.at(0);
|
||||||
|
const QString outPortName = outParts.at(1);
|
||||||
|
const QString inStableId = inParts.at(0);
|
||||||
|
const QString inPortName = inParts.at(1);
|
||||||
|
|
||||||
|
QMutexLocker lock(&m_nodesMutex);
|
||||||
|
for (const auto &nodeEntry : m_nodes) {
|
||||||
|
const NodeInfo &node = nodeEntry;
|
||||||
|
if (node.stableId == outStableId) {
|
||||||
|
for (const auto &port : node.outputPorts) {
|
||||||
|
if (port.name == outPortName) {
|
||||||
|
outNodeId = node.id;
|
||||||
|
outPortId = port.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.stableId == inStableId) {
|
||||||
|
for (const auto &port : node.inputPorts) {
|
||||||
|
if (port.name == inPortName) {
|
||||||
|
inNodeId = node.id;
|
||||||
|
inPortId = port.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outNodeId != 0 && outPortId != 0 && inNodeId != 0 && inPortId != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString PipeWireController::dumpGraph() const
|
QString PipeWireController::dumpGraph() const
|
||||||
|
|
@ -710,6 +1032,9 @@ void PipeWireController::handleNodeInfo(uint32_t id, const struct spa_dict *prop
|
||||||
qDebug() << "Node changed:" << node.id << node.name;
|
qDebug() << "Node changed:" << node.id << node.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateLinkIntentsForNode(id);
|
||||||
|
tryRestoreLinks();
|
||||||
}
|
}
|
||||||
|
|
||||||
void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *props)
|
void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *props)
|
||||||
|
|
@ -737,6 +1062,8 @@ void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *prop
|
||||||
uint32_t nodeId = nodeIdStr ? static_cast<uint32_t>(atoi(nodeIdStr)) : 0;
|
uint32_t nodeId = nodeIdStr ? static_cast<uint32_t>(atoi(nodeIdStr)) : 0;
|
||||||
PortInfo port(id, nodeId, portName, direction);
|
PortInfo port(id, nodeId, portName, direction);
|
||||||
|
|
||||||
|
bool emitChanged = false;
|
||||||
|
NodeInfo nodeSnapshot;
|
||||||
{
|
{
|
||||||
QMutexLocker lock(&m_nodesMutex);
|
QMutexLocker lock(&m_nodesMutex);
|
||||||
m_ports.insert(id, port);
|
m_ports.insert(id, port);
|
||||||
|
|
@ -751,9 +1078,18 @@ void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *prop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ports.append(port);
|
ports.append(port);
|
||||||
|
nodeSnapshot = node;
|
||||||
|
emitChanged = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (emitChanged) {
|
||||||
|
emit nodeChanged(nodeSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLinkIntentsForNode(nodeId);
|
||||||
|
tryRestoreLinks();
|
||||||
|
|
||||||
qDebug() << "Port added:" << id << portName << "direction:" << direction;
|
qDebug() << "Port added:" << id << portName << "direction:" << direction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -782,6 +1118,8 @@ void PipeWireController::handleLinkInfo(uint32_t id, const struct spa_dict *prop
|
||||||
|
|
||||||
emit linkAdded(link);
|
emit linkAdded(link);
|
||||||
|
|
||||||
|
rememberLinkIntent(link);
|
||||||
|
|
||||||
qDebug() << "Link added:" << id << "from" << outputNode << ":" << outputPort
|
qDebug() << "Link added:" << id << "from" << outputNode << ":" << outputPort
|
||||||
<< "to" << inputNode << ":" << inputPort;
|
<< "to" << inputNode << ":" << inputPort;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,19 @@
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
#include <QMutex>
|
#include <QMutex>
|
||||||
#include <QAtomicInteger>
|
#include <QAtomicInteger>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QSet>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <spa/utils/ringbuffer.h>
|
||||||
|
|
||||||
struct pw_thread_loop;
|
struct pw_thread_loop;
|
||||||
struct pw_context;
|
struct pw_context;
|
||||||
struct pw_core;
|
struct pw_core;
|
||||||
struct pw_registry;
|
struct pw_registry;
|
||||||
struct pw_stream;
|
struct pw_stream;
|
||||||
|
struct pw_proxy;
|
||||||
struct spa_hook;
|
struct spa_hook;
|
||||||
struct spa_dict;
|
struct spa_dict;
|
||||||
namespace Potato {
|
namespace Potato {
|
||||||
|
|
@ -38,6 +44,9 @@ public:
|
||||||
float nodeMeterPeak(uint32_t nodeId) const;
|
float nodeMeterPeak(uint32_t nodeId) const;
|
||||||
void ensureNodeMeter(uint32_t nodeId, const QString &targetName, bool captureSink);
|
void ensureNodeMeter(uint32_t nodeId, const QString &targetName, bool captureSink);
|
||||||
void removeNodeMeter(uint32_t nodeId);
|
void removeNodeMeter(uint32_t nodeId);
|
||||||
|
bool setNodeVolume(uint32_t nodeId, float volume, bool mute);
|
||||||
|
bool createVirtualSink(const QString &name, const QString &description, int channels, int rate);
|
||||||
|
bool createVirtualSource(const QString &name, const QString &description, int channels, int rate);
|
||||||
|
|
||||||
uint32_t createLink(uint32_t outputNodeId, uint32_t outputPortId,
|
uint32_t createLink(uint32_t outputNodeId, uint32_t outputPortId,
|
||||||
uint32_t inputNodeId, uint32_t inputPortId);
|
uint32_t inputNodeId, uint32_t inputPortId);
|
||||||
|
|
@ -72,6 +81,16 @@ private:
|
||||||
void handleLinkInfo(uint32_t id, const struct ::spa_dict *props);
|
void handleLinkInfo(uint32_t id, const struct ::spa_dict *props);
|
||||||
bool setupMeterStream();
|
bool setupMeterStream();
|
||||||
void teardownMeterStream();
|
void teardownMeterStream();
|
||||||
|
bool createVirtualDevice(const QString &name, const QString &description,
|
||||||
|
const char *factoryName, const char *mediaClass,
|
||||||
|
int channels, int rate);
|
||||||
|
void rememberLinkIntent(const LinkInfo &link);
|
||||||
|
void handleLinkRemoval(uint32_t linkId);
|
||||||
|
void tryRestoreLinks();
|
||||||
|
void updateLinkIntentsForNode(uint32_t nodeId);
|
||||||
|
bool buildLinkIntentKey(const LinkInfo &link, QString &key) const;
|
||||||
|
bool resolveLinkIntentKey(const QString &key, uint32_t &outNodeId, uint32_t &outPortId,
|
||||||
|
uint32_t &inNodeId, uint32_t &inPortId) const;
|
||||||
|
|
||||||
void lock();
|
void lock();
|
||||||
void unlock();
|
void unlock();
|
||||||
|
|
@ -89,10 +108,18 @@ private:
|
||||||
QMap<uint32_t, NodeInfo> m_nodes;
|
QMap<uint32_t, NodeInfo> m_nodes;
|
||||||
QMap<uint32_t, PortInfo> m_ports;
|
QMap<uint32_t, PortInfo> m_ports;
|
||||||
QMap<uint32_t, LinkInfo> m_links;
|
QMap<uint32_t, LinkInfo> m_links;
|
||||||
|
QSet<QString> m_linkIntents;
|
||||||
|
QHash<uint32_t, QString> m_linkIntentKeys;
|
||||||
|
QSet<uint32_t> m_userRemovedLinks;
|
||||||
|
|
||||||
QAtomicInteger<bool> m_connected{false};
|
QAtomicInteger<bool> m_connected{false};
|
||||||
QAtomicInteger<bool> m_initialized{false};
|
QAtomicInteger<bool> m_initialized{false};
|
||||||
std::atomic<float> m_meterPeak{0.0f};
|
std::atomic<float> m_meterPeak{0.0f};
|
||||||
|
mutable spa_ringbuffer m_meterRing{};
|
||||||
|
mutable std::vector<uint8_t> m_meterRingData;
|
||||||
|
std::atomic<bool> m_meterRingReady{false};
|
||||||
|
|
||||||
|
std::vector<struct pw_proxy*> m_virtualDevices;
|
||||||
|
|
||||||
mutable QMutex m_meterMutex;
|
mutable QMutex m_meterMutex;
|
||||||
QMap<uint32_t, NodeMeter*> m_nodeMeters;
|
QMap<uint32_t, NodeMeter*> m_nodeMeters;
|
||||||
|
|
|
||||||
293
src/presets/PresetManager.cpp
Normal file
293
src/presets/PresetManager.cpp
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
#include "presets/PresetManager.h"
|
||||||
|
|
||||||
|
#include "pipewire/pipewirecontroller.h"
|
||||||
|
#include "gui/PipeWireGraphModel.h"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
PresetManager::PresetManager(Potato::PipeWireController *controller,
|
||||||
|
PipeWireGraphModel *model,
|
||||||
|
QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_controller(controller)
|
||||||
|
, m_model(model)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PresetManager::savePreset(const QString &path) const
|
||||||
|
{
|
||||||
|
if (!m_controller || !m_model || path.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject root = buildPreset();
|
||||||
|
const QJsonDocument doc(root);
|
||||||
|
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
file.write(doc.toJson(QJsonDocument::Indented));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PresetManager::loadPreset(const QString &path)
|
||||||
|
{
|
||||||
|
if (!m_controller || !m_model || path.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||||
|
if (!doc.isObject()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyPreset(doc.object());
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject PresetManager::buildPreset() const
|
||||||
|
{
|
||||||
|
QJsonObject root;
|
||||||
|
root["version"] = QString("1.0");
|
||||||
|
|
||||||
|
const QVector<Potato::NodeInfo> nodes = m_controller->nodes();
|
||||||
|
QHash<uint32_t, Potato::NodeInfo> nodesById;
|
||||||
|
for (const auto &node : nodes) {
|
||||||
|
nodesById.insert(node.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray virtualDevices;
|
||||||
|
for (const auto &node : nodes) {
|
||||||
|
if (node.type != Potato::NodeType::Virtual) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
QJsonObject device;
|
||||||
|
device["name"] = node.name;
|
||||||
|
device["description"] = node.description;
|
||||||
|
device["stable_id"] = node.stableId;
|
||||||
|
const int channels = std::max(node.inputPorts.size(), node.outputPorts.size());
|
||||||
|
device["channels"] = channels > 0 ? channels : 2;
|
||||||
|
device["rate"] = 48000;
|
||||||
|
device["media_class"] = mediaClassToString(node.mediaClass);
|
||||||
|
virtualDevices.append(device);
|
||||||
|
}
|
||||||
|
root["virtual_devices"] = virtualDevices;
|
||||||
|
|
||||||
|
QJsonArray routing;
|
||||||
|
const QVector<Potato::LinkInfo> links = m_controller->links();
|
||||||
|
for (const auto &link : links) {
|
||||||
|
if (!nodesById.contains(link.outputNodeId) || !nodesById.contains(link.inputNodeId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const Potato::NodeInfo &outNode = nodesById.value(link.outputNodeId);
|
||||||
|
const Potato::NodeInfo &inNode = nodesById.value(link.inputNodeId);
|
||||||
|
|
||||||
|
QString outPortName;
|
||||||
|
QString inPortName;
|
||||||
|
if (!findPortName(outNode, link.outputPortId, outPortName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!findPortName(inNode, link.inputPortId, inPortName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject route;
|
||||||
|
route["source"] = QString("%1:%2").arg(outNode.stableId, outPortName);
|
||||||
|
route["target"] = QString("%1:%2").arg(inNode.stableId, inPortName);
|
||||||
|
route["volume"] = 1.0;
|
||||||
|
route["muted"] = false;
|
||||||
|
routing.append(route);
|
||||||
|
}
|
||||||
|
root["routing"] = routing;
|
||||||
|
|
||||||
|
QJsonObject volumes;
|
||||||
|
QJsonObject mutes;
|
||||||
|
const QHash<QString, NodeVolumeState> volumeStates = m_model->volumeStates();
|
||||||
|
for (auto it = volumeStates.cbegin(); it != volumeStates.cend(); ++it) {
|
||||||
|
volumes[it.key()] = it.value().volume;
|
||||||
|
mutes[it.key()] = it.value().mute;
|
||||||
|
}
|
||||||
|
root["persistent_volumes"] = volumes;
|
||||||
|
root["persistent_mutes"] = mutes;
|
||||||
|
|
||||||
|
root["ui_layout"] = m_model->layoutJson();
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PresetManager::applyPreset(const QJsonObject &root)
|
||||||
|
{
|
||||||
|
const QVector<Potato::NodeInfo> nodes = m_controller->nodes();
|
||||||
|
QHash<QString, Potato::NodeInfo> nodesByStableId;
|
||||||
|
for (const auto &node : nodes) {
|
||||||
|
if (!node.stableId.isEmpty()) {
|
||||||
|
nodesByStableId.insert(node.stableId, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonArray virtualDevices = root.value("virtual_devices").toArray();
|
||||||
|
for (const auto &entry : virtualDevices) {
|
||||||
|
const QJsonObject device = entry.toObject();
|
||||||
|
const QString stableId = device.value("stable_id").toString();
|
||||||
|
const QString name = device.value("name").toString();
|
||||||
|
const QString description = device.value("description").toString();
|
||||||
|
const QString mediaClassValue = device.value("media_class").toString();
|
||||||
|
const int channels = device.value("channels").toInt(2);
|
||||||
|
const int rate = device.value("rate").toInt(48000);
|
||||||
|
|
||||||
|
if (!stableId.isEmpty() && nodesByStableId.contains(stableId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Potato::MediaClass mediaClass = mediaClassFromString(mediaClassValue);
|
||||||
|
if (mediaClass == Potato::MediaClass::AudioSource) {
|
||||||
|
m_controller->createVirtualSource(name, description, channels, rate);
|
||||||
|
} else {
|
||||||
|
m_controller->createVirtualSink(name, description, channels, rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonArray routing = root.value("routing").toArray();
|
||||||
|
for (const auto &entry : routing) {
|
||||||
|
const QJsonObject route = entry.toObject();
|
||||||
|
const QString source = route.value("source").toString();
|
||||||
|
const QString target = route.value("target").toString();
|
||||||
|
|
||||||
|
uint32_t outNodeId = 0;
|
||||||
|
uint32_t outPortId = 0;
|
||||||
|
uint32_t inNodeId = 0;
|
||||||
|
uint32_t inPortId = 0;
|
||||||
|
|
||||||
|
if (!parsePortKey(source, outNodeId, outPortId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!parsePortKey(target, inNodeId, inPortId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_controller->createLink(outNodeId, outPortId, inNodeId, inPortId);
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<QString, NodeVolumeState> volumeStates;
|
||||||
|
const QJsonObject volumes = root.value("persistent_volumes").toObject();
|
||||||
|
const QJsonObject mutes = root.value("persistent_mutes").toObject();
|
||||||
|
for (auto it = volumes.begin(); it != volumes.end(); ++it) {
|
||||||
|
NodeVolumeState state;
|
||||||
|
state.volume = static_cast<float>(it.value().toDouble(1.0));
|
||||||
|
state.mute = mutes.value(it.key()).toBool(false);
|
||||||
|
volumeStates.insert(it.key(), state);
|
||||||
|
}
|
||||||
|
if (!volumeStates.isEmpty()) {
|
||||||
|
m_model->applyVolumeStates(volumeStates);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject layout = root.value("ui_layout").toObject();
|
||||||
|
if (!layout.isEmpty()) {
|
||||||
|
m_model->applyLayoutJson(layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PresetManager::findPortName(const Potato::NodeInfo &node, uint32_t portId, QString &portName) const
|
||||||
|
{
|
||||||
|
for (const auto &port : node.inputPorts) {
|
||||||
|
if (port.id == portId) {
|
||||||
|
portName = port.name;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto &port : node.outputPorts) {
|
||||||
|
if (port.id == portId) {
|
||||||
|
portName = port.name;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PresetManager::parsePortKey(const QString &key, uint32_t &nodeId, uint32_t &portId) const
|
||||||
|
{
|
||||||
|
const int split = key.lastIndexOf(':');
|
||||||
|
if (split <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString stableId = key.left(split);
|
||||||
|
const QString portName = key.mid(split + 1);
|
||||||
|
if (stableId.isEmpty() || portName.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QVector<Potato::NodeInfo> nodes = m_controller->nodes();
|
||||||
|
for (const auto &node : nodes) {
|
||||||
|
if (node.stableId != stableId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const auto &port : node.inputPorts) {
|
||||||
|
if (port.name == portName) {
|
||||||
|
nodeId = node.id;
|
||||||
|
portId = port.id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto &port : node.outputPorts) {
|
||||||
|
if (port.name == portName) {
|
||||||
|
nodeId = node.id;
|
||||||
|
portId = port.id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString PresetManager::portKey(const Potato::NodeInfo &node, const Potato::PortInfo &port) const
|
||||||
|
{
|
||||||
|
return QString("%1:%2").arg(node.stableId, port.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString PresetManager::mediaClassToString(Potato::MediaClass mediaClass) const
|
||||||
|
{
|
||||||
|
switch (mediaClass) {
|
||||||
|
case Potato::MediaClass::AudioSink:
|
||||||
|
return QString("Audio/Sink");
|
||||||
|
case Potato::MediaClass::AudioSource:
|
||||||
|
return QString("Audio/Source");
|
||||||
|
case Potato::MediaClass::AudioDuplex:
|
||||||
|
return QString("Audio/Duplex");
|
||||||
|
case Potato::MediaClass::Stream:
|
||||||
|
return QString("Stream");
|
||||||
|
default:
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Potato::MediaClass PresetManager::mediaClassFromString(const QString &value) const
|
||||||
|
{
|
||||||
|
if (value.contains("Audio/Source")) {
|
||||||
|
return Potato::MediaClass::AudioSource;
|
||||||
|
}
|
||||||
|
if (value.contains("Audio/Duplex")) {
|
||||||
|
return Potato::MediaClass::AudioDuplex;
|
||||||
|
}
|
||||||
|
if (value.contains("Audio/Sink")) {
|
||||||
|
return Potato::MediaClass::AudioSink;
|
||||||
|
}
|
||||||
|
if (value.contains("Stream")) {
|
||||||
|
return Potato::MediaClass::Stream;
|
||||||
|
}
|
||||||
|
return Potato::MediaClass::Unknown;
|
||||||
|
}
|
||||||
38
src/presets/PresetManager.h
Normal file
38
src/presets/PresetManager.h
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "pipewire/nodeinfo.h"
|
||||||
|
|
||||||
|
class PipeWireGraphModel;
|
||||||
|
|
||||||
|
namespace Potato {
|
||||||
|
class PipeWireController;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PresetManager : public QObject
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit PresetManager(Potato::PipeWireController *controller,
|
||||||
|
PipeWireGraphModel *model,
|
||||||
|
QObject *parent = nullptr);
|
||||||
|
|
||||||
|
bool savePreset(const QString &path) const;
|
||||||
|
bool loadPreset(const QString &path);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QJsonObject buildPreset() const;
|
||||||
|
bool applyPreset(const QJsonObject &root);
|
||||||
|
bool findPortName(const Potato::NodeInfo &node, uint32_t portId, QString &portName) const;
|
||||||
|
bool parsePortKey(const QString &key, uint32_t &nodeId, uint32_t &portId) const;
|
||||||
|
QString portKey(const Potato::NodeInfo &node, const Potato::PortInfo &port) const;
|
||||||
|
QString mediaClassToString(Potato::MediaClass mediaClass) const;
|
||||||
|
Potato::MediaClass mediaClassFromString(const QString &value) const;
|
||||||
|
|
||||||
|
Potato::PipeWireController *m_controller = nullptr;
|
||||||
|
PipeWireGraphModel *m_model = nullptr;
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue