Save presets
This commit is contained in:
parent
ecfb59501a
commit
b3a1d2b7f3
9 changed files with 542 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1221,9 +1221,9 @@ private:
|
||||||
### Milestone 4: Virtual Devices & State Management
|
### Milestone 4: Virtual Devices & State Management
|
||||||
**Estimated Time:** 2 weeks
|
**Estimated Time:** 2 weeks
|
||||||
- [x] 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#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>
|
||||||
|
|
@ -175,6 +176,38 @@ 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);
|
auto *createVirtualSinkAction = new QAction(QString("Create Virtual Sink"), m_view);
|
||||||
connect(createVirtualSinkAction, &QAction::triggered, [this]() {
|
connect(createVirtualSinkAction, &QAction::triggered, [this]() {
|
||||||
const int index = ++m_virtualSinkCount;
|
const int index = ++m_virtualSinkCount;
|
||||||
|
|
@ -233,6 +266,8 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
|
||||||
|
|
||||||
m_meterProfileTimer.start();
|
m_meterProfileTimer.start();
|
||||||
m_meterProfileReady = true;
|
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)
|
||||||
|
|
@ -508,6 +543,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)) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class QTimer;
|
||||||
class QScrollArea;
|
class QScrollArea;
|
||||||
class QVBoxLayout;
|
class QVBoxLayout;
|
||||||
class QSplitter;
|
class QSplitter;
|
||||||
|
class PresetManager;
|
||||||
|
|
||||||
class GraphEditorWidget : public QWidget
|
class GraphEditorWidget : public QWidget
|
||||||
{
|
{
|
||||||
|
|
@ -46,6 +47,7 @@ private:
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -77,4 +79,5 @@ private:
|
||||||
qint64 m_meterProfileNanos = 0;
|
qint64 m_meterProfileNanos = 0;
|
||||||
qint64 m_meterProfileMax = 0;
|
qint64 m_meterProfileMax = 0;
|
||||||
int m_meterProfileFrames = 0;
|
int m_meterProfileFrames = 0;
|
||||||
|
PresetManager *m_presetManager = nullptr;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -144,12 +144,19 @@ QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const
|
||||||
slider->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
slider->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
||||||
slider->setToolTip(QString("Volume"));
|
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]() {
|
const auto applyVolume = [this, pipewireId, slider, muteButton]() {
|
||||||
if (!m_controller || pipewireId == 0) {
|
if (!m_controller || pipewireId == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const float volume = static_cast<float>(slider->value()) / 100.0f;
|
const float volume = static_cast<float>(slider->value()) / 100.0f;
|
||||||
m_controller->setNodeVolume(pipewireId, volume, muteButton->isChecked());
|
m_controller->setNodeVolume(pipewireId, volume, muteButton->isChecked());
|
||||||
|
m_nodeVolumeState.insert(pipewireId, NodeVolumeState{volume, muteButton->isChecked()});
|
||||||
};
|
};
|
||||||
|
|
||||||
QObject::connect(slider, &QSlider::valueChanged, widget, [applyVolume](int) { applyVolume(); });
|
QObject::connect(slider, &QSlider::valueChanged, widget, [applyVolume](int) { applyVolume(); });
|
||||||
|
|
@ -694,6 +701,135 @@ 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);
|
||||||
|
m_nodeVolumeState.insert(node.id, state);
|
||||||
|
m_controller->setNodeVolume(node.id, state.volume, state.mute);
|
||||||
|
|
||||||
|
auto nodeIt = m_pwToNode.find(node.id);
|
||||||
|
if (nodeIt == m_pwToNode.end()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
auto widgetIt = m_nodeWidgets.find(nodeIt->second);
|
||||||
|
if (widgetIt == m_nodeWidgets.end()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
QWidget *widget = widgetIt->second;
|
||||||
|
if (!widget) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void PipeWireGraphModel::saveLayout() const
|
void PipeWireGraphModel::saveLayout() const
|
||||||
{
|
{
|
||||||
const QString path = layoutFilePath();
|
const QString path = layoutFilePath();
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@
|
||||||
|
|
||||||
class QWidget;
|
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
|
||||||
|
|
@ -72,6 +77,10 @@ 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);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QWidget *nodeWidget(QtNodes::NodeId nodeId) const;
|
QWidget *nodeWidget(QtNodes::NodeId nodeId) const;
|
||||||
|
|
@ -100,4 +109,5 @@ private:
|
||||||
bool m_hasSplitterSizes = false;
|
bool m_hasSplitterSizes = false;
|
||||||
mutable std::unordered_map<QtNodes::NodeId, QWidget*> m_nodeWidgets;
|
mutable std::unordered_map<QtNodes::NodeId, QWidget*> m_nodeWidgets;
|
||||||
std::unordered_map<QtNodes::NodeId, QSize> m_nodeSizes;
|
std::unordered_map<QtNodes::NodeId, QSize> m_nodeSizes;
|
||||||
|
mutable QHash<uint32_t, NodeVolumeState> m_nodeVolumeState;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -545,7 +545,7 @@ bool PipeWireController::createVirtualSink(const QString &name, const QString &d
|
||||||
|
|
||||||
bool PipeWireController::createVirtualSource(const QString &name, const QString &description, int channels, int rate)
|
bool PipeWireController::createVirtualSource(const QString &name, const QString &description, int channels, int rate)
|
||||||
{
|
{
|
||||||
return createVirtualDevice(name, description, "support.null-audio-source", "Audio/Source", channels, 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
|
||||||
|
|
|
||||||
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