From 4c6dd4c8df0691271cc1aaaa3a2dd82f0f0b88fc Mon Sep 17 00:00:00 2001 From: dennislemennace Date: Thu, 21 May 2026 17:28:53 +0100 Subject: [PATCH] feat(presets): add per-preset output device association MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a preset optionally remember which output device was active when it was saved, and switch to that device automatically when the preset is loaded — making profile switching a single action instead of two. - PresetManager: new _presetDevices map (name → {deviceId, useDefault}), persisted in preset_devices.json alongside the existing preset_rules.json. setPresetDevice/clearPresetDevice/hasPresetDevice/applyPresetDevice API. applyPresetDevice() sets AppConfig::AudioOutputDevice + AudioOutputUseDefault, which triggers a live switch via PipewireAudioService::onAppConfigUpdated — the same path SettingsFragment uses. A _suppressDeviceApply guard prevents a feedback loop when a device-change rule fires and loads a preset via onOutputDeviceChanged. rename() and remove() keep the map in sync. - PresetFragment: "Remember output device" checkbox (opt-in; default behaviour unchanged). Hidden on PulseAudio builds alongside the existing rules button, since the PulseAudio device-switch path is stubbed. Co-Authored-By: Claude Sonnet 4.6 --- src/data/PresetManager.cpp | 103 ++++++++++++++++++++++ src/data/PresetManager.h | 16 ++++ src/interface/fragment/PresetFragment.cpp | 21 ++++- src/interface/fragment/PresetFragment.ui | 10 +++ 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src/data/PresetManager.cpp b/src/data/PresetManager.cpp index b7cc3aea..11e24b1b 100644 --- a/src/data/PresetManager.cpp +++ b/src/data/PresetManager.cpp @@ -5,6 +5,7 @@ #include "model/PresetListModel.h" #include +#include #include #include #include @@ -12,6 +13,7 @@ PresetManager::PresetManager() : _presetModel(new PresetListModel(this)) { loadRules(); + loadPresetDevices(); } bool PresetManager::exists(const QString &name) const @@ -38,6 +40,11 @@ bool PresetManager::loadFromPath(const QString &filename) QFile::copy(src, dest); DspConfig::instance().load(); + + // If this preset remembers an output device, switch to it. The preset name is + // the file's base name; presets without an association leave the device untouched. + applyPresetDevice(QFileInfo(filename).completeBaseName()); + Log::debug("Loaded " + filename); return true; } @@ -55,6 +62,14 @@ void PresetManager::rename(const QString &name, const QString &newName) { QFile::rename(path, QDir(path).filePath(newName + ".conf")); } + + // Keep the device association in sync with the preset's new name + if (_presetDevices.contains(name)) + { + _presetDevices[newName] = _presetDevices.take(name); + savePresetDevices(); + } + this->_presetModel->rescan(); } @@ -64,6 +79,7 @@ bool PresetManager::remove(const QString &name) if (QFile::exists(path)) { QFile::remove(path); + clearPresetDevice(name); this->_presetModel->rescan(); return true; } @@ -96,7 +112,11 @@ void PresetManager::onOutputDeviceChanged(const QString &deviceName, const QStri { QString defaultRouteId = QString::fromStdString(RouteListModel::makeDefaultRoute().name); auto executeRule = [this, deviceName, outputRouteId, defaultRouteId](const PresetRule& rule){ + // A rule loads a preset *because* the device changed; do not let the loaded + // preset re-apply a device, which would feed back into this handler. + _suppressDeviceApply = true; loadFromPath(AppConfig::instance().getPath("presets/" + rule.preset + ".conf")); + _suppressDeviceApply = false; emit presetAutoloaded(deviceName, rule.routeName, rule.routeId == defaultRouteId); }; @@ -203,3 +223,86 @@ QVector PresetManager::rules() const return _rules; } +QString PresetManager::presetDevicesPath() const +{ + return AppConfig::instance().getPath("preset_devices.json"); +} + +void PresetManager::loadPresetDevices() +{ + _presetDevices.clear(); + + QFile json(presetDevicesPath()); + if(!json.exists()) + { + return; + } + + json.open(QFile::ReadOnly); + QJsonObject root = QJsonDocument::fromJson(json.readAll()).object(); + json.close(); + + for(auto it = root.constBegin(); it != root.constEnd(); ++it) + { + _presetDevices.insert(it.key(), it.value().toObject()); + } +} + +void PresetManager::savePresetDevices() +{ + QFile json(presetDevicesPath()); + if(!json.open(QIODevice::WriteOnly)){ + Log::error("PresetManager::savePresetDevices: Cannot open json file"); + return; + } + + QJsonObject root; + for(auto it = _presetDevices.constBegin(); it != _presetDevices.constEnd(); ++it) + { + root.insert(it.key(), it.value()); + } + + json.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + json.close(); +} + +bool PresetManager::hasPresetDevice(const QString &name) const +{ + return _presetDevices.contains(name); +} + +void PresetManager::setPresetDevice(const QString &name) +{ + QJsonObject entry; + entry["deviceId"] = AppConfig::instance().get(AppConfig::AudioOutputDevice); + entry["useDefault"] = AppConfig::instance().get(AppConfig::AudioOutputUseDefault); + _presetDevices[name] = entry; + savePresetDevices(); +} + +void PresetManager::clearPresetDevice(const QString &name) +{ + if(_presetDevices.remove(name) > 0) + { + savePresetDevices(); + } +} + +void PresetManager::applyPresetDevice(const QString &name) +{ + if(_suppressDeviceApply || !_presetDevices.contains(name)) + { + return; + } + + const QJsonObject entry = _presetDevices.value(name); + + // Mirror SettingsFragment: setting these AppConfig keys triggers a live device + // switch via PipewireAudioService::onAppConfigUpdated. + AppConfig::instance().set(AppConfig::AudioOutputUseDefault, entry["useDefault"].toBool()); + if(!entry["useDefault"].toBool()) + { + AppConfig::instance().set(AppConfig::AudioOutputDevice, entry["deviceId"].toString()); + } +} + diff --git a/src/data/PresetManager.h b/src/data/PresetManager.h index 74cc0def..ede40eed 100644 --- a/src/data/PresetManager.h +++ b/src/data/PresetManager.h @@ -3,6 +3,8 @@ #include "PresetRule.h" +#include +#include #include #include @@ -29,6 +31,13 @@ class PresetManager : public QObject bool addRule(const PresetRule& rule); void removeRule(const QString &deviceId, const QString &routeId); + // Per-preset output device association (PipeWire only). + // Lets a preset optionally remember an output device and switch to it on load. + bool hasPresetDevice(const QString& name) const; + void setPresetDevice(const QString& name); + void clearPresetDevice(const QString& name); + void applyPresetDevice(const QString& name); + PresetListModel *presetModel() const; signals: @@ -49,9 +58,16 @@ public slots: void saveRules() const; private: QVector _rules; + QMap _presetDevices; PresetListModel* _presetModel; + // Suppresses applyPresetDevice() while a preset is being auto-loaded by a + // device->preset rule, to avoid a device-change feedback loop. + bool _suppressDeviceApply = false; QString rulesPath() const; + QString presetDevicesPath() const; + void loadPresetDevices(); + void savePresetDevices(); }; #endif // PRESETMANAGER_H diff --git a/src/interface/fragment/PresetFragment.cpp b/src/interface/fragment/PresetFragment.cpp index af3b6b62..81d6f14a 100644 --- a/src/interface/fragment/PresetFragment.cpp +++ b/src/interface/fragment/PresetFragment.cpp @@ -27,7 +27,9 @@ PresetFragment::PresetFragment(IAudioService* service, QWidget *parent) : ui->load->setEnabled(false); #ifdef USE_PULSEAUDIO + // Device switching is only implemented for the PipeWire backend ui->rules->setVisible(false); + ui->rememberDevice->setVisible(false); #endif connect(ui->add, &QPushButton::clicked, this, &PresetFragment::onAddClicked); @@ -58,7 +60,9 @@ void PresetFragment::onSelectionChanged(const QItemSelection &selected, const QI return; } - ui->presetName->setText(PresetManager::instance().presetModel()->data(selected.indexes().first(), Qt::UserRole).toString()); + const QString selectedName = PresetManager::instance().presetModel()->data(selected.indexes().first(), Qt::UserRole).toString(); + ui->presetName->setText(selectedName); + ui->rememberDevice->setChecked(PresetManager::instance().hasPresetDevice(selectedName)); } void PresetFragment::onAddClicked() @@ -68,8 +72,19 @@ void PresetFragment::onAddClicked() return; } - PresetManager::instance().save(ui->presetName->text()); - ui->presetName->text() = ""; + const QString name = ui->presetName->text(); + PresetManager::instance().save(name); + + if(ui->rememberDevice->isChecked()) + { + PresetManager::instance().setPresetDevice(name); + } + else + { + PresetManager::instance().clearPresetDevice(name); + } + + ui->presetName->clear(); } void PresetFragment::onRemoveClicked() diff --git a/src/interface/fragment/PresetFragment.ui b/src/interface/fragment/PresetFragment.ui index 6c8d423a..96f8e66f 100755 --- a/src/interface/fragment/PresetFragment.ui +++ b/src/interface/fragment/PresetFragment.ui @@ -69,6 +69,16 @@ + + + + Save the current output device with this preset and switch to it when the preset is loaded + + + Remember output device + + +