From 4c6dd4c8df0691271cc1aaaa3a2dd82f0f0b88fc Mon Sep 17 00:00:00 2001 From: dennislemennace Date: Thu, 21 May 2026 17:28:53 +0100 Subject: [PATCH 1/2] 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 + + + From 80a529346b165beef26e8f8e42fc4fb6ef90b5c2 Mon Sep 17 00:00:00 2001 From: dennislemennace Date: Fri, 5 Jun 2026 03:45:52 +0100 Subject: [PATCH 2/2] fix(presets): scope device-apply to managed presets; add checkbox to tab order loadFromPath() is also reached by MainWindow::loadExternalFile(), which can open any .conf on disk. Guard applyPresetDevice() so it only runs for files in the presets directory, preventing an imported file from switching the output device merely because its base name collides with a saved preset's name. Also add the new "Remember output device" checkbox to the .ui tab order so it is reachable in a defined order via keyboard navigation. Co-Authored-By: Claude Opus 4.8 --- src/data/PresetManager.cpp | 12 +++++++++--- src/interface/fragment/PresetFragment.ui | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/data/PresetManager.cpp b/src/data/PresetManager.cpp index 11e24b1b..d5f30e66 100644 --- a/src/data/PresetManager.cpp +++ b/src/data/PresetManager.cpp @@ -41,9 +41,15 @@ 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()); + // If this preset remembers an output device, switch to it. Associations belong to + // managed presets only (those in the presets directory), keyed by file base name. + // An external file imported via loadExternalFile() must not switch the device just + // because its base name happens to collide with a saved preset. + if (QFileInfo(filename).dir().absolutePath() == + QDir(AppConfig::instance().getPath("presets/")).absolutePath()) + { + applyPresetDevice(QFileInfo(filename).completeBaseName()); + } Log::debug("Loaded " + filename); return true; diff --git a/src/interface/fragment/PresetFragment.ui b/src/interface/fragment/PresetFragment.ui index 96f8e66f..26cd96ff 100755 --- a/src/interface/fragment/PresetFragment.ui +++ b/src/interface/fragment/PresetFragment.ui @@ -151,6 +151,7 @@ presetName add + rememberDevice remove load