diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index d0e416157..c39ef78bd 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -258,6 +258,7 @@ Singleton { property string calendarBackend: Spec.SPEC.calendarBackend.def property string defaultTaskCalendarId: Spec.SPEC.defaultTaskCalendarId.def property bool audioShowStreamDevices: Spec.SPEC.audioShowStreamDevices.def + property bool audioMono: Spec.SPEC.audioMono.def property string clockFormat: Spec.SPEC.clockFormat.def readonly property bool localeUses24Hour: { const fmt = Qt.locale().timeFormat(Locale.ShortFormat).replace(/'[^']*'/g, ""); diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 25e2349c5..d0f2b016d 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -339,6 +339,9 @@ var LOCAL_SPEC = { audioShowStreamDevices: { def: false }, + audioMono: { + def: false + }, windSpeedUnit: { def: "kmh" }, diff --git a/quickshell/Modules/Settings/AudioTab.qml b/quickshell/Modules/Settings/AudioTab.qml index ff92d04a3..b451423f9 100644 --- a/quickshell/Modules/Settings/AudioTab.qml +++ b/quickshell/Modules/Settings/AudioTab.qml @@ -8,6 +8,8 @@ import qs.Modules.Settings.Widgets Item { id: root + readonly property var log: Log.scoped("AudioTab") + LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.childrenInherit: true @@ -119,6 +121,32 @@ Item { SettingsPage { id: mainColumn + SettingsCard { + tab: "audio" + tags: ["audio", "accessibility", "mono"] + title: I18n.tr("Mono Audio", "Audio settings: mono audio toggle") + settingKey: "audioMono" + iconName: "a11y" + + SettingsToggleRow { + tab: "audio" + tags: ["audio", "mono"] + settingKey: "audioMono" + text: I18n.tr("Mono Audio", "Audio settings: mono audio toggle") + description: I18n.tr("Mix all stereo content into a single channel", "Audio settings mono description") + enabled: AudioService.monoSettingSupported + checked: SettingsData.audioMono + onToggled: checked => { + SettingsData.set("audioMono", checked); + AudioService.setMonoSetting(checked, (ok, message) => { + if (!ok) { + SettingsData.set("audioMono", !checked); + } + }); + } + } + } + SettingsCard { tab: "audio" tags: ["audio", "device", "output", "speaker"] diff --git a/quickshell/Services/AudioService.qml b/quickshell/Services/AudioService.qml index d03e09e97..c076bed66 100644 --- a/quickshell/Services/AudioService.qml +++ b/quickshell/Services/AudioService.qml @@ -62,6 +62,9 @@ Singleton { readonly property int sourceVolumePercent: Math.round((source?.audio?.volume ?? 0) * 100) readonly property int wheelVolumeStep: SettingsData.audioWheelScrollAmount + property bool monoSettingSupported: false + property string monoSettingBackend: "" + signal micMuteChanged signal micVolumeChanged signal audioOutputCycled(string deviceName, string deviceIcon) @@ -134,6 +137,15 @@ Singleton { } } + Connections { + target: SettingsData + function onAudioMonoChanged() { + if (root.monoSettingSupported) { + root.setMonoSetting(SettingsData.audioMono); + } + } + } + function setSink(node: PwNode): bool { if (!node) return false; @@ -1276,6 +1288,78 @@ EOFCONFIG return root.sink.audio.muted ? "Audio muted" : "Audio unmuted"; } + // Mono audio toggle + property bool _monoQueryInProgress: false + + function queryMonoSetting(callback) { + if (root._monoQueryInProgress) { + return; + } + root._monoQueryInProgress = true; + // Try pactl first (PipeWire ≥1.4.10 /1.6.0) + Proc.runCommand("audio-query-mono", ["env", "LC_ALL=C", "pactl", "send-message", "/core", "pipewire-pulse:force-mono-output", ""], (output, exitCode) => { + if (exitCode === 0 && output) { + const trimmed = output.trim(); + if (trimmed === "true" || trimmed === "false") { + root.monoSettingSupported = true; + root.monoSettingBackend = "pactl"; + root._monoQueryInProgress = false; + callback(true, trimmed === "true"); + return; + } + } + // Fallback to wpctl settings + Proc.runCommand("audio-query-mono-wpctl", ["env", "LC_ALL=C", "wpctl", "settings", "node.features.audio.mono"], (output2, exitCode2) => { + if (exitCode2 === 0 && output2) { + const m = output2.match(/\b(true|false)\b/); + if (m) { + root.monoSettingSupported = true; + root.monoSettingBackend = "wpctl"; + root._monoQueryInProgress = false; + callback(true, m[1] === "true"); + return; + } + } + root.monoSettingSupported = false; + root.monoSettingBackend = ""; + root._monoQueryInProgress = false; + callback(false, false); + }); + }, 0); + } + + // Re-apply the saved preference: the live PipeWire value is runtime-only and resets on restart + function applyMonoStartupPreference() { + if (!SettingsData._hasLoaded) + return; + queryMonoSetting((supported, currentValue) => { + if (supported && currentValue !== SettingsData.audioMono) + root.setMonoSetting(SettingsData.audioMono); + }); + } + + function setMonoSetting(enabled, callback) { + const boolStr = enabled ? "true" : "false"; + const backend = root.monoSettingBackend; + if (backend === "pactl") { + Proc.runCommand("audio-set-mono", ["env", "LC_ALL=C", "pactl", "send-message", "/core", "pipewire-pulse:force-mono-output", boolStr], (output, exitCode) => { + const ok = exitCode === 0; + if (callback) { + callback(ok, ok ? "Mono audio enabled" : (output || "Failed to set mono audio")); + } + }, 0); + } else if (backend === "wpctl") { + Proc.runCommand("audio-set-mono-wpctl", ["env", "LC_ALL=C", "wpctl", "settings", "node.features.audio.mono", boolStr], (output, exitCode) => { + const ok = exitCode === 0; + if (callback) { + callback(ok, ok ? "Mono audio enabled" : (output || "Failed to set mono audio")); + } + }, 0); + } else if (callback) { + callback(false, "Mono audio not supported"); + } + } + function setMicVolume(percentage) { if (!root.source?.audio) { return "No audio source available"; @@ -1363,6 +1447,27 @@ EOFCONFIG return root.toggleMicMute(); } + function setmono(status: string): string { + const enabled = status === "true"; + if (!root.monoSettingSupported) { + return "Mono audio not supported"; + } + // Async: persist setting only on success + root.setMonoSetting(enabled, (ok, message) => { + if (ok) { + SettingsData.set("audioMono", enabled); + } + }); + return "Mono audio toggle sent"; + } + + function getmono(): string { + if (!root.monoSettingSupported) { + return "Mono audio not supported"; + } + return `Mono Audio: ${root.monoSettingBackend} ${SettingsData.audioMono ? "enabled" : "disabled"}`; + } + function status(): string { let result = "Audio Status:\n"; @@ -1436,10 +1541,17 @@ EOFCONFIG checkSoundThemeSupport(); } + readonly property bool settingsLoaded: SettingsData._hasLoaded + onSettingsLoadedChanged: { + if (settingsLoaded) + root.applyMonoStartupPreference(); + } + Component.onCompleted: { rebuildTypedNodeLists(); loadDeviceAliases(); if (SettingsData.soundsEnabled && SettingsData.useSystemSoundTheme) getCurrentSoundTheme(); + root.applyMonoStartupPreference(); } } diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 95a615cf7..8d937f9c4 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -11284,6 +11284,31 @@ "parentLabel": "Sound & media", "icon": "mic" }, + { + "section": "audioMono", + "label": "Mono Audio", + "tabIndex": 29, + "category": "Audio", + "keywords": [ + "accessibility", + "audio", + "channel", + "content", + "headphones", + "media", + "microphone", + "mono", + "pipewire", + "single", + "sound", + "speaker", + "stereo", + "volume" + ], + "parentLabel": "Sound & media", + "icon": "a11y", + "description": "Mix all stereo content into a single channel" + }, { "section": "audioOutputDevices", "label": "Output devices",