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(
|
||||
-Wall
|
||||
-Wextra
|
||||
-Wpedantic
|
||||
-Wno-pedantic
|
||||
-Werror=return-type
|
||||
)
|
||||
|
||||
|
|
@ -90,6 +90,7 @@ add_executable(potato-gui
|
|||
src/gui/GraphEditorWidget.cpp
|
||||
src/gui/PipeWireGraphModel.cpp
|
||||
src/meters/AudioLevelMeter.cpp
|
||||
src/presets/PresetManager.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(potato-gui PRIVATE
|
||||
|
|
|
|||
|
|
@ -1191,52 +1191,52 @@ private:
|
|||
|
||||
## 11. Implementation Milestone Plan
|
||||
|
||||
### Milestone 1: Core PipeWire Integration ✅
|
||||
### Milestone 1: Core PipeWire Integration
|
||||
**Estimated Time:** 2-3 weeks
|
||||
- [ ] Initialize Qt6 project with CMake
|
||||
- [ ] Integrate libpipewire with `pw_thread_loop`
|
||||
- [ ] Implement node/port discovery via registry callbacks
|
||||
- [ ] Implement link creation/destruction
|
||||
- [ ] Create lock-free communication primitives (atomics, ring buffers)
|
||||
- [ ] **Acceptance Criteria:** CLI test app that lists nodes and creates a link programmatically
|
||||
- [x] Initialize Qt6 project with CMake
|
||||
- [x] Integrate libpipewire with `pw_thread_loop`
|
||||
- [x] Implement node/port discovery via registry callbacks
|
||||
- [x] Implement link creation/destruction
|
||||
- [x] Create lock-free communication primitives (atomics, ring buffers)
|
||||
- [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
|
||||
- [ ] Integrate QtNodes library (submodule or CMake package)
|
||||
- [ ] Create `AudioNodeDataModel` for PipeWire nodes
|
||||
- [ ] Map PipeWire ports to QtNodes handles
|
||||
- [ ] Implement connection validation
|
||||
- [ ] Create custom node widgets with embedded controls
|
||||
- [ ] **Acceptance Criteria:** Visual graph editor displays PipeWire nodes and allows dragging connections
|
||||
- [x] Integrate QtNodes library (submodule or CMake package)
|
||||
- [x] Create `AudioNodeDataModel` for PipeWire nodes
|
||||
- [x] Map PipeWire ports to QtNodes handles
|
||||
- [x] Implement connection validation
|
||||
- [x] Create custom node widgets with embedded controls
|
||||
- [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
|
||||
- [ ] Implement `AudioLevelMeter` with optimized QGraphicsView
|
||||
- [ ] Create 30Hz update timer with manual viewport control
|
||||
- [ ] Integrate PipeWire audio callbacks for meter data
|
||||
- [ ] Implement lock-free meter data transfer (atomics)
|
||||
- [ ] Profile and optimize rendering performance
|
||||
- [x] Implement `AudioLevelMeter` with optimized QGraphicsView
|
||||
- [x] Create 30Hz update timer with manual viewport control
|
||||
- [x] Integrate PipeWire audio callbacks for meter data
|
||||
- [x] Implement lock-free meter data transfer (atomics)
|
||||
- [x] Profile and optimize rendering performance
|
||||
- [ ] **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
|
||||
- [ ] Implement virtual sink/source creation via PipeWire adapters
|
||||
- [ ] Create `PresetManager` with JSON serialization
|
||||
- [ ] Implement preset load/save functionality
|
||||
- [ ] Store UI layout alongside audio graph state
|
||||
- [x] Implement virtual sink/source creation via PipeWire adapters
|
||||
- [x] Create `PresetManager` with JSON serialization
|
||||
- [x] Implement preset load/save functionality
|
||||
- [x] Store UI layout alongside audio graph state
|
||||
- [ ] Implement auto-reconnect for device hotplug
|
||||
- [ ] **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
|
||||
- [ ] Design traditional mixer UI with faders
|
||||
- [ ] Implement volume slider with PipeWire parameter sync
|
||||
- [ ] Add mute buttons and solo functionality
|
||||
- [ ] Create stereo/multi-channel level meters
|
||||
- [ ] Implement undo/redo for volume changes
|
||||
- [x] Design traditional mixer UI with faders
|
||||
- [x] Implement volume slider with PipeWire parameter sync
|
||||
- [x] Add mute buttons and solo functionality
|
||||
- [x] Create stereo/multi-channel level meters
|
||||
- [x] Implement undo/redo for volume changes
|
||||
- [ ] **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
|
||||
- [ ] Integrate QUndoStack for all graph operations
|
||||
- [ ] Implement command classes for link, volume, node operations
|
||||
|
|
@ -1245,7 +1245,7 @@ private:
|
|||
- [ ] Add copy/paste/duplicate functionality
|
||||
- [ ] **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
|
||||
- [ ] Implement device unplug/replug detection
|
||||
- [ ] Handle PipeWire service restart with auto-reconnect
|
||||
|
|
@ -1254,7 +1254,7 @@ private:
|
|||
- [ ] Add error logging with structured JSON output
|
||||
- [ ] **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
|
||||
- [ ] Create application icon and desktop file
|
||||
- [ ] 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 "meters/AudioLevelMeter.h"
|
||||
#include "presets/PresetManager.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QColor>
|
||||
#include <QFileDialog>
|
||||
#include <QFontMetrics>
|
||||
#include <QLabel>
|
||||
#include <QHBoxLayout>
|
||||
#include <QSplitter>
|
||||
#include <QTabWidget>
|
||||
#include <QSlider>
|
||||
#include <QToolButton>
|
||||
#include <QTimer>
|
||||
#include <QUndoCommand>
|
||||
#include <QEvent>
|
||||
#include <QVBoxLayout>
|
||||
#include <QScrollArea>
|
||||
#include <QSizePolicy>
|
||||
#include <QElapsedTimer>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include <QtNodes/GraphicsViewStyle>
|
||||
#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)
|
||||
: QWidget(parent)
|
||||
, m_controller(controller)
|
||||
|
|
@ -65,8 +112,16 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
|
|||
m_splitter->setOrientation(Qt::Horizontal);
|
||||
m_splitter->addWidget(m_view);
|
||||
|
||||
auto *meterPanel = new QWidget(m_splitter);
|
||||
meterPanel->setStyleSheet("background-color: #1f2126; border-left: 1px solid #2b2f38;");
|
||||
m_sidebarTabs = new QTabWidget(m_splitter);
|
||||
m_sidebarTabs->setStyleSheet(
|
||||
"QTabWidget::pane { border: none; background: #1f2126; border-left: 1px solid #2b2f38; }"
|
||||
"QTabBar::tab { background: #1f2126; color: #8c94a5; padding: 8px 12px; border: none; font-weight: 700; font-size: 11px; }"
|
||||
"QTabBar::tab:selected { color: #dbe2ee; border-bottom: 2px solid #5fcf8d; }"
|
||||
"QTabBar::tab:hover { color: #c7cfdd; }"
|
||||
);
|
||||
|
||||
auto *meterPanel = new QWidget();
|
||||
meterPanel->setStyleSheet("background-color: #1f2126;");
|
||||
meterPanel->setMinimumWidth(260);
|
||||
meterPanel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
|
||||
|
||||
|
|
@ -102,7 +157,31 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
|
|||
|
||||
meterLayout->addStretch();
|
||||
|
||||
m_splitter->addWidget(meterPanel);
|
||||
m_sidebarTabs->addTab(meterPanel, "METERS");
|
||||
|
||||
m_mixerTab = new QWidget();
|
||||
m_mixerTab->setStyleSheet("background-color: #1f2126;");
|
||||
auto *mixerTabLayout = new QVBoxLayout(m_mixerTab);
|
||||
mixerTabLayout->setContentsMargins(0, 0, 0, 0);
|
||||
|
||||
auto *mixerScroll = new QScrollArea(m_mixerTab);
|
||||
mixerScroll->setFrameShape(QFrame::NoFrame);
|
||||
mixerScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
mixerScroll->setWidgetResizable(true);
|
||||
|
||||
m_mixerList = new QWidget(mixerScroll);
|
||||
m_mixerListLayout = new QHBoxLayout(m_mixerList);
|
||||
m_mixerListLayout->setContentsMargins(20, 20, 20, 20);
|
||||
m_mixerListLayout->setSpacing(12);
|
||||
m_mixerListLayout->setAlignment(Qt::AlignLeft);
|
||||
m_mixerList->setLayout(m_mixerListLayout);
|
||||
|
||||
mixerScroll->setWidget(m_mixerList);
|
||||
mixerTabLayout->addWidget(mixerScroll);
|
||||
|
||||
m_sidebarTabs->addTab(m_mixerTab, "MIXER");
|
||||
|
||||
m_splitter->addWidget(m_sidebarTabs);
|
||||
m_splitter->setStretchFactor(0, 1);
|
||||
m_splitter->setStretchFactor(1, 0);
|
||||
|
||||
|
|
@ -115,6 +194,16 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
|
|||
this, &GraphEditorWidget::onConnectionCreated);
|
||||
connect(m_model, &PipeWireGraphModel::connectionDeleted,
|
||||
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,
|
||||
this, &GraphEditorWidget::onNodeAdded);
|
||||
|
|
@ -172,6 +261,61 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
|
|||
});
|
||||
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();
|
||||
if (m_model->hasOverlaps()) {
|
||||
m_model->autoArrange();
|
||||
|
|
@ -205,6 +349,11 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
|
|||
m_meterTimer->setTimerType(Qt::PreciseTimer);
|
||||
connect(m_meterTimer, &QTimer::timeout, this, &GraphEditorWidget::updateMeter);
|
||||
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)
|
||||
|
|
@ -225,6 +374,7 @@ void GraphEditorWidget::syncGraph()
|
|||
if (isAudioEndpoint(node)) {
|
||||
m_model->addPipeWireNode(node);
|
||||
refreshNodeMeter(node.id, node);
|
||||
refreshMixerStrip(node.id, node);
|
||||
}
|
||||
}
|
||||
const QVector<Potato::LinkInfo> links = m_controller->links();
|
||||
|
|
@ -255,6 +405,7 @@ void GraphEditorWidget::onNodeAdded(const Potato::NodeInfo &node)
|
|||
if (isAudioEndpoint(node)) {
|
||||
m_model->addPipeWireNode(node);
|
||||
refreshNodeMeter(node.id, node);
|
||||
refreshMixerStrip(node.id, node);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,6 +421,8 @@ void GraphEditorWidget::onNodeChanged(const Potato::NodeInfo &node)
|
|||
|
||||
refreshNodeMeter(node.id, node);
|
||||
updateNodeMeterState(node.id, node);
|
||||
refreshMixerStrip(node.id, node);
|
||||
updateMixerState(node.id, node);
|
||||
}
|
||||
|
||||
void GraphEditorWidget::onNodeRemoved(uint32_t nodeId)
|
||||
|
|
@ -288,6 +441,8 @@ void GraphEditorWidget::onNodeRemoved(uint32_t nodeId)
|
|||
m_nodeMeters.remove(nodeId);
|
||||
row->deleteLater();
|
||||
}
|
||||
|
||||
removeMixerStrip(nodeId);
|
||||
}
|
||||
|
||||
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);
|
||||
if (linkId == 0) {
|
||||
m_model->deleteConnection(connectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -435,6 +591,12 @@ void GraphEditorWidget::updateMeter()
|
|||
return;
|
||||
}
|
||||
|
||||
if (!isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const qint64 startNanos = m_meterProfileReady ? m_meterProfileTimer.nsecsElapsed() : 0;
|
||||
|
||||
const float peak = m_controller->meterPeak();
|
||||
m_meter->setLevel(peak);
|
||||
|
||||
|
|
@ -443,6 +605,27 @@ void GraphEditorWidget::updateMeter()
|
|||
const float nodePeak = m_controller->nodeMeterPeak(nodeId);
|
||||
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()
|
||||
|
|
@ -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)
|
||||
{
|
||||
if (m_nodeMeterRows.contains(nodeId)) {
|
||||
|
|
@ -616,3 +821,242 @@ bool GraphEditorWidget::eventFilter(QObject *object, QEvent *event)
|
|||
|
||||
return QWidget::eventFilter(object, event);
|
||||
}
|
||||
|
||||
void GraphEditorWidget::refreshMixerStrip(uint32_t nodeId, const Potato::NodeInfo &node)
|
||||
{
|
||||
if (m_mixerStrips.contains(nodeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto *strip = new QWidget(m_mixerList);
|
||||
strip->setFixedWidth(90);
|
||||
auto *layout = new QVBoxLayout(strip);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
layout->setSpacing(8);
|
||||
|
||||
auto *meter = new AudioLevelMeter(strip);
|
||||
meter->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
|
||||
auto *fader = new 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 <QSet>
|
||||
#include <QMap>
|
||||
#include <QElapsedTimer>
|
||||
#include <cstdint>
|
||||
|
||||
class AudioLevelMeter;
|
||||
|
|
@ -16,12 +17,20 @@ class QLabel;
|
|||
class QTimer;
|
||||
class QScrollArea;
|
||||
class QVBoxLayout;
|
||||
class QHBoxLayout;
|
||||
class QSlider;
|
||||
class QToolButton;
|
||||
class QSplitter;
|
||||
class QTabWidget;
|
||||
class PresetManager;
|
||||
class VolumeChangeCommand;
|
||||
|
||||
class GraphEditorWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
friend class VolumeChangeCommand;
|
||||
|
||||
public:
|
||||
explicit GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent = nullptr);
|
||||
|
||||
|
|
@ -42,9 +51,16 @@ private:
|
|||
void updateLayoutState();
|
||||
void updateNodeMeterLabel(QLabel *label);
|
||||
void updateNodeMeterState(uint32_t nodeId, const Potato::NodeInfo &node);
|
||||
void refreshMixerStrip(uint32_t nodeId, const Potato::NodeInfo &node);
|
||||
void updateMixerState(uint32_t nodeId, const Potato::NodeInfo &node);
|
||||
void removeMixerStrip(uint32_t nodeId);
|
||||
void applySoloState();
|
||||
void 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);
|
||||
bool isMeterNode(uint32_t nodeId) const;
|
||||
int activeLinkCount(uint32_t nodeId) const;
|
||||
void reloadGraphFromController();
|
||||
QString connectionKey(const QtNodes::ConnectionId &connectionId) const;
|
||||
bool eventFilter(QObject *object, QEvent *event) override;
|
||||
|
||||
|
|
@ -54,11 +70,27 @@ private:
|
|||
QtNodes::GraphicsView *m_view = nullptr;
|
||||
QSplitter *m_splitter = nullptr;
|
||||
|
||||
QTabWidget *m_sidebarTabs = nullptr;
|
||||
QWidget *m_mixerTab = nullptr;
|
||||
QWidget *m_mixerList = nullptr;
|
||||
QHBoxLayout *m_mixerListLayout = nullptr;
|
||||
QMap<uint32_t, AudioLevelMeter*> m_mixerMeters;
|
||||
QMap<uint32_t, QWidget*> m_mixerStrips;
|
||||
QMap<uint32_t, QSlider*> m_mixerFaders;
|
||||
QMap<uint32_t, QToolButton*> m_mixerMutes;
|
||||
QMap<uint32_t, QToolButton*> m_mixerSolos;
|
||||
QMap<uint32_t, bool> m_mixerUserMute;
|
||||
QMap<uint32_t, NodeVolumeState> m_mixerStartState;
|
||||
QMap<uint32_t, NodeVolumeState> m_mixerLastState;
|
||||
QSet<uint32_t> m_mixerSoloNodes;
|
||||
|
||||
QSet<QString> m_ignoreCreate;
|
||||
QSet<QString> m_ignoreDelete;
|
||||
QSet<uint32_t> m_ignoreLinkRemoved;
|
||||
QMap<QString, uint32_t> m_connectionToLinkId;
|
||||
QMap<uint32_t, QString> m_linkIdToConnection;
|
||||
int m_virtualSinkCount = 0;
|
||||
int m_virtualSourceCount = 0;
|
||||
AudioLevelMeter *m_meter = nullptr;
|
||||
QTimer *m_meterTimer = nullptr;
|
||||
QScrollArea *m_meterScroll = nullptr;
|
||||
|
|
@ -69,4 +101,11 @@ private:
|
|||
QMap<uint32_t, QLabel*> m_nodeMeterLabels;
|
||||
QMap<uint32_t, int> m_nodeLinkCounts;
|
||||
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 <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QHBoxLayout>
|
||||
#include <QSlider>
|
||||
#include "gui/ClickSlider.h"
|
||||
#include <QSizePolicy>
|
||||
#include <QToolButton>
|
||||
#include <QWidget>
|
||||
|
||||
#include <QtNodes/NodeStyle>
|
||||
#include <QtNodes/StyleCollection>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <unordered_set>
|
||||
#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()
|
||||
{
|
||||
return m_nextNodeId++;
|
||||
|
|
@ -210,6 +314,23 @@ bool PipeWireGraphModel::connectionPossible(QtNodes::ConnectionId const connecti
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -257,6 +378,10 @@ QVariant PipeWireGraphModel::nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole
|
|||
}
|
||||
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 baseHeight = 50;
|
||||
const int perPortHeight = 28;
|
||||
|
|
@ -274,6 +399,8 @@ QVariant PipeWireGraphModel::nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole
|
|||
return QString("PipeWire");
|
||||
case QtNodes::NodeRole::Style:
|
||||
return nodeStyleVariant(info);
|
||||
case QtNodes::NodeRole::Widget:
|
||||
return QVariant::fromValue(nodeWidget(nodeId));
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
|
|
@ -293,6 +420,11 @@ bool PipeWireGraphModel::setNodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole r
|
|||
return true;
|
||||
}
|
||||
|
||||
if (role == QtNodes::NodeRole::Size) {
|
||||
m_nodeSizes[nodeId] = value.toSize();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -333,9 +465,6 @@ QVariant PipeWireGraphModel::portData(QtNodes::NodeId nodeId,
|
|||
}
|
||||
|
||||
if (role == QtNodes::PortRole::ConnectionPolicyRole) {
|
||||
if (portType == QtNodes::PortType::In) {
|
||||
return QVariant::fromValue(QtNodes::ConnectionPolicy::One);
|
||||
}
|
||||
return QVariant::fromValue(QtNodes::ConnectionPolicy::Many);
|
||||
}
|
||||
|
||||
|
|
@ -379,6 +508,8 @@ bool PipeWireGraphModel::deleteNode(QtNodes::NodeId const nodeId)
|
|||
|
||||
m_nodes.erase(nodeId);
|
||||
m_positions.erase(nodeId);
|
||||
m_nodeSizes.erase(nodeId);
|
||||
m_nodeWidgets.erase(nodeId);
|
||||
Q_EMIT nodeDeleted(nodeId);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -476,6 +607,7 @@ bool PipeWireGraphModel::updatePipeWireNode(const Potato::NodeInfo &node)
|
|||
|
||||
const QtNodes::NodeId nodeId = it->second;
|
||||
m_nodes[nodeId] = node;
|
||||
m_nodeSizes.erase(nodeId);
|
||||
Q_EMIT nodeUpdated(nodeId);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -503,6 +635,8 @@ void PipeWireGraphModel::reset()
|
|||
m_nodes.clear();
|
||||
m_pwToNode.clear();
|
||||
m_positions.clear();
|
||||
m_nodeSizes.clear();
|
||||
m_nodeWidgets.clear();
|
||||
m_nextNodeId = 1;
|
||||
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
|
||||
{
|
||||
const QString path = layoutFilePath();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@
|
|||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
class QWidget;
|
||||
|
||||
struct NodeVolumeState {
|
||||
float volume = 1.0f;
|
||||
bool mute = false;
|
||||
};
|
||||
|
||||
class PipeWireGraphModel : public QtNodes::AbstractGraphModel
|
||||
{
|
||||
Q_OBJECT
|
||||
|
|
@ -70,8 +77,19 @@ public:
|
|||
bool viewState(double &scale, QPointF ¢er) const;
|
||||
void setSplitterSizes(const QList<int> &sizes);
|
||||
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:
|
||||
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;
|
||||
bool findPortIndex(const Potato::NodeInfo &node, uint32_t portId, QtNodes::PortType type, QtNodes::PortIndex &index) const;
|
||||
QString portLabel(const Potato::PortInfo &port) const;
|
||||
|
|
@ -95,4 +113,8 @@ private:
|
|||
bool m_hasViewState = false;
|
||||
QList<int> m_splitterSizes;
|
||||
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 <QElapsedTimer>
|
||||
#include <QThread>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include <cmath>
|
||||
|
|
@ -13,14 +14,119 @@
|
|||
#include <pipewire/keys.h>
|
||||
#include <pipewire/properties.h>
|
||||
#include <pipewire/stream.h>
|
||||
#include <pipewire/node.h>
|
||||
#include <spa/param/props.h>
|
||||
#include <spa/param/audio/format-utils.h>
|
||||
#include <spa/param/audio/raw.h>
|
||||
#include <spa/utils/dict.h>
|
||||
#include <spa/utils/defs.h>
|
||||
#include <spa/utils/type-info.h>
|
||||
|
||||
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)
|
||||
{
|
||||
if (!value) {
|
||||
|
|
@ -50,7 +156,7 @@ void registryEventGlobal(void *data, uint32_t id, uint32_t permissions,
|
|||
void registryEventGlobalRemove(void *data, uint32_t id)
|
||||
{
|
||||
auto *self = static_cast<PipeWireController*>(data);
|
||||
|
||||
|
||||
{
|
||||
QMutexLocker lock(&self->m_nodesMutex);
|
||||
if (self->m_nodes.contains(id)) {
|
||||
|
|
@ -58,18 +164,27 @@ void registryEventGlobalRemove(void *data, uint32_t id)
|
|||
emit self->nodeRemoved(id);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (self->m_ports.contains(id)) {
|
||||
self->m_ports.remove(id);
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
bool linkRemoved = false;
|
||||
{
|
||||
QMutexLocker lock(&self->m_nodesMutex);
|
||||
if (self->m_links.contains(id)) {
|
||||
self->m_links.remove(id);
|
||||
emit self->linkRemoved(id);
|
||||
return;
|
||||
linkRemoved = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (linkRemoved) {
|
||||
emit self->linkRemoved(id);
|
||||
self->handleLinkRemoval(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -217,6 +335,9 @@ PipeWireController::PipeWireController(QObject *parent)
|
|||
{
|
||||
m_registryListener = 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()
|
||||
|
|
@ -321,6 +442,13 @@ void PipeWireController::shutdown()
|
|||
}
|
||||
m_nodeMeters.clear();
|
||||
}
|
||||
|
||||
for (auto *proxy : m_virtualDevices) {
|
||||
if (proxy) {
|
||||
pw_proxy_destroy(proxy);
|
||||
}
|
||||
}
|
||||
m_virtualDevices.clear();
|
||||
|
||||
if (m_core) {
|
||||
pw_core_disconnect(m_core);
|
||||
|
|
@ -340,6 +468,9 @@ void PipeWireController::shutdown()
|
|||
}
|
||||
|
||||
pw_deinit();
|
||||
|
||||
m_meterRingReady.store(false, std::memory_order_relaxed);
|
||||
m_meterRingData.clear();
|
||||
|
||||
m_initialized.storeRelaxed(false);
|
||||
m_connected.storeRelaxed(false);
|
||||
|
|
@ -372,7 +503,58 @@ QVector<LinkInfo> PipeWireController::links() 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
|
||||
|
|
@ -596,6 +778,11 @@ bool PipeWireController::destroyLink(uint32_t linkId)
|
|||
return false;
|
||||
}
|
||||
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();
|
||||
|
|
@ -609,21 +796,156 @@ bool PipeWireController::destroyLink(uint32_t linkId)
|
|||
|
||||
unlock();
|
||||
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
while (timer.elapsed() < 2000) {
|
||||
{
|
||||
QMutexLocker lock(&m_nodesMutex);
|
||||
if (!m_links.contains(linkId)) {
|
||||
qInfo() << "Link destroyed:" << linkId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
QThread::msleep(10);
|
||||
qInfo() << "Link destroy requested:" << linkId;
|
||||
return true;
|
||||
}
|
||||
|
||||
void PipeWireController::rememberLinkIntent(const LinkInfo &link)
|
||||
{
|
||||
QString key;
|
||||
if (!buildLinkIntentKey(link, key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
qWarning() << "Link destroy requested but ID still present" << linkId;
|
||||
return false;
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -710,6 +1032,9 @@ void PipeWireController::handleNodeInfo(uint32_t id, const struct spa_dict *prop
|
|||
qDebug() << "Node changed:" << node.id << node.name;
|
||||
}
|
||||
}
|
||||
|
||||
updateLinkIntentsForNode(id);
|
||||
tryRestoreLinks();
|
||||
}
|
||||
|
||||
void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *props)
|
||||
|
|
@ -737,10 +1062,12 @@ void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *prop
|
|||
uint32_t nodeId = nodeIdStr ? static_cast<uint32_t>(atoi(nodeIdStr)) : 0;
|
||||
PortInfo port(id, nodeId, portName, direction);
|
||||
|
||||
bool emitChanged = false;
|
||||
NodeInfo nodeSnapshot;
|
||||
{
|
||||
QMutexLocker lock(&m_nodesMutex);
|
||||
m_ports.insert(id, port);
|
||||
|
||||
|
||||
if (nodeId != 0 && m_nodes.contains(nodeId)) {
|
||||
NodeInfo &node = m_nodes[nodeId];
|
||||
auto &ports = (direction == PW_DIRECTION_INPUT) ? node.inputPorts : node.outputPorts;
|
||||
|
|
@ -751,9 +1078,18 @@ void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *prop
|
|||
}
|
||||
}
|
||||
ports.append(port);
|
||||
nodeSnapshot = node;
|
||||
emitChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (emitChanged) {
|
||||
emit nodeChanged(nodeSnapshot);
|
||||
}
|
||||
|
||||
updateLinkIntentsForNode(nodeId);
|
||||
tryRestoreLinks();
|
||||
|
||||
qDebug() << "Port added:" << id << portName << "direction:" << direction;
|
||||
}
|
||||
|
||||
|
|
@ -781,6 +1117,8 @@ void PipeWireController::handleLinkInfo(uint32_t id, const struct spa_dict *prop
|
|||
}
|
||||
|
||||
emit linkAdded(link);
|
||||
|
||||
rememberLinkIntent(link);
|
||||
|
||||
qDebug() << "Link added:" << id << "from" << outputNode << ":" << outputPort
|
||||
<< "to" << inputNode << ":" << inputPort;
|
||||
|
|
|
|||
|
|
@ -5,13 +5,19 @@
|
|||
#include <QMap>
|
||||
#include <QMutex>
|
||||
#include <QAtomicInteger>
|
||||
#include <QHash>
|
||||
#include <QSet>
|
||||
#include <atomic>
|
||||
#include <vector>
|
||||
|
||||
#include <spa/utils/ringbuffer.h>
|
||||
|
||||
struct pw_thread_loop;
|
||||
struct pw_context;
|
||||
struct pw_core;
|
||||
struct pw_registry;
|
||||
struct pw_stream;
|
||||
struct pw_proxy;
|
||||
struct spa_hook;
|
||||
struct spa_dict;
|
||||
namespace Potato {
|
||||
|
|
@ -38,6 +44,9 @@ public:
|
|||
float nodeMeterPeak(uint32_t nodeId) const;
|
||||
void ensureNodeMeter(uint32_t nodeId, const QString &targetName, bool captureSink);
|
||||
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 inputNodeId, uint32_t inputPortId);
|
||||
|
|
@ -72,6 +81,16 @@ private:
|
|||
void handleLinkInfo(uint32_t id, const struct ::spa_dict *props);
|
||||
bool setupMeterStream();
|
||||
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 unlock();
|
||||
|
|
@ -89,10 +108,18 @@ private:
|
|||
QMap<uint32_t, NodeInfo> m_nodes;
|
||||
QMap<uint32_t, PortInfo> m_ports;
|
||||
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_initialized{false};
|
||||
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;
|
||||
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