From db5a1a3eef4110d86140234182947a7a42ab4734 Mon Sep 17 00:00:00 2001 From: blacku Date: Thu, 30 Jul 2026 14:36:16 +0200 Subject: [PATCH] fix(audio-switcher): update mute state after toggle (#158) --- audio-switcher/plugin.toml | 2 +- audio-switcher/service.luau | 23 +++- audio-switcher/tests/mute_state_test.lua | 156 +++++++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 audio-switcher/tests/mute_state_test.lua diff --git a/audio-switcher/plugin.toml b/audio-switcher/plugin.toml index 2724ccb..fbef509 100644 --- a/audio-switcher/plugin.toml +++ b/audio-switcher/plugin.toml @@ -1,6 +1,6 @@ id = "blackbartblues/audio-switcher" name = "Audio Switcher" -version = "0.2.1" +version = "0.2.2" plugin_api = 9 author = "blackbartblues" license = "MIT" diff --git a/audio-switcher/service.luau b/audio-switcher/service.luau index 8fb4397..933b368 100644 --- a/audio-switcher/service.luau +++ b/audio-switcher/service.luau @@ -1050,9 +1050,28 @@ end local function toggleMute(command, kind) local target = kind == "output" and "@DEFAULT_SINK@" or "@DEFAULT_SOURCE@" local pactlCommand = kind == "output" and "set-sink-mute" or "set-source-mute" + local currentMuted = kind == "output" and snapshot.outputMuted or false + if kind == "input" then currentMuted = snapshot.inputMuted end + local requestedMuted = not currentMuted runPactl({ pactlCommand, target, "toggle" }, function(result) - resultMessage(command, result.exitCode == 0, trim(result.stderr)) - refreshAll() + local ok = result.exitCode == 0 + resultMessage(command, ok, trim(result.stderr)) + if not ok then + refreshAll() + return + end + + local volume + if kind == "output" then + snapshot.outputMuted = requestedMuted + volume = snapshot.outputVolume + else + snapshot.inputMuted = requestedMuted + volume = snapshot.inputVolume + end + updateActiveDeviceVolume(kind, volume, requestedMuted) + publishSnapshot() + refreshCurrentVolume(kind) end) end diff --git a/audio-switcher/tests/mute_state_test.lua b/audio-switcher/tests/mute_state_test.lua new file mode 100644 index 0000000..1849399 --- /dev/null +++ b/audio-switcher/tests/mute_state_test.lua @@ -0,0 +1,156 @@ +local function clone(value) + if type(value) ~= "table" then return value end + local result = {} + for key, item in pairs(value) do result[clone(key)] = clone(item) end + return result +end + +local values = {} +local watchers = {} +local pending = {} +local streamCallback = nil + +local decoded = { + INFO = { + default_sink_name = "sink.main", + default_source_name = "source.main", + }, + SINKS_UNMUTED = { + { + name = "sink.main", + description = "Main output", + mute = false, + volume = { front_left = { value_percent = "42%" } }, + properties = {}, + }, + }, + SOURCES_UNMUTED = { + { + name = "source.main", + description = "Main input", + mute = false, + volume = { front_left = { value_percent = "37%" } }, + properties = {}, + }, + }, + OUTPUT_VOLUME = { + volume = { front_left = { value_percent = "42%" } }, + }, + INPUT_VOLUME = { + volume = { front_left = { value_percent = "37%" } }, + }, + MUTED = { mute = true }, + UNMUTED = { mute = false }, +} + +noctalia = { + pluginDataDir = function() return nil end, + getConfig = function() return nil end, + commandExists = function(command) return command == "pactl" end, + readFile = function() return nil end, + writeFile = function() return true end, + setUpdateInterval = function() end, + runAsync = function(command, callback) + pending[#pending + 1] = { command = command, callback = callback } + return true + end, + runStream = function(_command, callback) + streamCallback = callback + return true + end, + json = { + decode = function(value) return clone(decoded[value]) end, + encode = function() return "PREFERENCES" end, + }, + string = { + trim = function(value) return tostring(value or ""):match("^%s*(.-)%s*$") or "" end, + }, + state = { + get = function(key) return clone(values[key]) end, + set = function(key, value) + values[key] = clone(value) + if watchers[key] ~= nil then watchers[key](clone(value)) end + end, + watch = function(key, callback) watchers[key] = callback end, + }, + tr = function(key) return key end, + log = function() end, + notify = function() end, + notifyError = function() end, +} + +local function complete(expected, stdout, exitCode) + local call = table.remove(pending, 1) + assert(call ~= nil, "expected pending command containing " .. expected) + assert(call.command:find(expected, 1, true) ~= nil, "unexpected command: " .. call.command) + call.callback({ + exitCode = exitCode or 0, + stdout = stdout or "", + stderr = "", + timedOut = false, + stdoutTruncated = false, + stderrTruncated = false, + }) +end + +local function snapshot() + return values["audio_switcher_snapshot"] +end + +local serviceFile = assert(io.open("service.luau", "r")) +local serviceSource = serviceFile:read("*a") +serviceFile:close() +serviceSource = serviceSource:gsub("([%w_%.]+)%s*%+=%s*([^\n]+)", "%1 = %1 + %2") +serviceSource = serviceSource:gsub("([%w_%.]+)%s*%-=%s*([^\n]+)", "%1 = %1 - %2") +assert(load(serviceSource, "@service.luau"))() +assert(type(streamCallback) == "function", "pactl subscription was not started") + +complete("'info'", "INFO") +complete("'list' 'sinks'", "SINKS_UNMUTED") +complete("'list' 'sources'", "SOURCES_UNMUTED") + +assert(snapshot().outputMuted == false, "initial output mute state is incorrect") +assert(snapshot().inputMuted == false, "initial input mute state is incorrect") + +noctalia.state.set("audio_switcher_command", { + requestId = "mute-output", + action = "toggle_output_mute", +}) +streamCallback("Event 'change' on sink #1") +complete("'set-sink-mute'", "") + +assert(snapshot().outputMuted == true, "successful output toggle did not update the snapshot immediately") +assert(snapshot().outputs[1].muted == true, "successful output toggle did not update the active device") +complete("'get-sink-volume'", "OUTPUT_VOLUME") +complete("'get-sink-mute'", "MUTED") +complete("'get-sink-volume'", "OUTPUT_VOLUME") +complete("'get-sink-mute'", "MUTED") +assert(snapshot().outputMuted == true, "authoritative output mute refresh lost the toggled state") + +noctalia.state.set("audio_switcher_command", { + requestId = "mute-input", + action = "toggle_input_mute", +}) +complete("'set-source-mute'", "") + +assert(snapshot().inputMuted == true, "successful input toggle did not update the snapshot immediately") +assert(snapshot().inputs[1].muted == true, "successful input toggle did not update the active device") +complete("'get-source-volume'", "INPUT_VOLUME") +complete("'get-source-mute'", "MUTED") +assert(snapshot().inputMuted == true, "authoritative input mute refresh lost the toggled state") + +noctalia.state.set("audio_switcher_command", { + requestId = "unmute-output", + action = "toggle_output_mute", +}) +complete("'set-sink-mute'", "") + +assert(snapshot().outputMuted == false, "successful output unmute did not update the snapshot immediately") +assert(snapshot().inputMuted == true, "output unmute changed the input mute state") +complete("'get-sink-volume'", "OUTPUT_VOLUME") +complete("'get-sink-mute'", "UNMUTED") +assert(snapshot().outputMuted == false, "authoritative output refresh lost the unmuted state") + +assert(#pending == 0, "mute toggles unexpectedly started a full device refresh") + +print("audio-switcher mute state tests: ok")