diff --git a/audio-switcher/README.md b/audio-switcher/README.md index f42780b..a6899c7 100644 --- a/audio-switcher/README.md +++ b/audio-switcher/README.md @@ -2,8 +2,9 @@ Audio Switcher puts PipeWire inputs, outputs, volume controls, and Bluetooth audio handoff in one compact Noctalia panel. Devices can be renamed or hidden, -and compositor keybinds can cycle devices or connect a specific Bluetooth -device by its persistent number. +multiple outputs can be grouped for simultaneous playback, and compositor +keybinds can cycle devices or connect a specific Bluetooth device by its +persistent number. ![Audio Switcher panel](screenshots/panel.webp) @@ -23,6 +24,8 @@ through visible inputs. Scrolling over it changes the output volume. Install `pactl`, `bluetoothctl`, and `sleep` on `PATH`. On Arch Linux they are provided by the `libpulse`, `bluez-utils`, and `coreutils` packages respectively. PipeWire's PulseAudio compatibility service and BlueZ must be running. +Output grouping also requires PulseAudio's or PipeWire Pulse's +`module-combine-sink`. ## Usage @@ -42,6 +45,12 @@ change a Bluetooth keybind number. The number is assigned automatically after a device connects successfully for the first time and can then be changed. Hidden devices remain available in the panel but are skipped by cycling commands. +To play through multiple outputs at once, open **Outputs**, choose **Group +outputs**, select at least two available physical outputs, then choose **Create +group**. The combined output becomes the default and current playback streams +move to it. Use **Disband** on its row to remove it; if it is active, playback +moves to the first available member before removal. + Use the settings button in the panel header to open Noctalia's plugin settings. ## Settings @@ -98,6 +107,12 @@ other Bluetooth audio devices connected to this computer. It cannot disconnect the target from another computer or phone; that device must release the target first unless it supports multipoint connections. +Output groups are `module-combine-sink` instances owned by the running audio +server. Audio Switcher rediscovers its groups after the plugin service restarts, +but they disappear if PipeWire/PulseAudio restarts. Combining outputs may add +latency and resampling CPU cost, especially when the devices use different +clocks or sample rates. + The plugin does not access the network. Its service spawns only the declared `pactl`, `bluetoothctl`, and `sleep` commands. The short sleep is used while waiting for a newly connected Bluetooth endpoint to appear. The panel may also diff --git a/audio-switcher/panel.luau b/audio-switcher/panel.luau index 5187be8..c91a0e2 100644 --- a/audio-switcher/panel.luau +++ b/audio-switcher/panel.luau @@ -31,6 +31,8 @@ local editSlot = "" local feedback = "" local feedbackError = false local requestCounter = 0 +local groupingOutputs = false +local selectedGroupOutputs = {} -- Slider onChange is also emitted when the host applies a new controlled value. -- Keep the last callback value only for onDragEnd; rendering from it would turn -- the first external update into a sticky local draft and freeze later updates. @@ -89,6 +91,14 @@ local function hiddenCount(kind) return count end +local function selectedGroupCount() + local count = 0 + for _, selected in pairs(selectedGroupOutputs) do + if selected then count += 1 end + end + return count +end + local function selectedIndex(values, selected) for index, value in ipairs(values) do if value == selected then return index - 1 end @@ -211,12 +221,56 @@ local function deviceRow(device, kind) local hideAction = function() sendCommand("set_hidden", { id = device.id, kind = kind, hidden = not device.hidden }) end + local selectAction = function() + selectedGroupOutputs[device.id] = not selectedGroupOutputs[device.id] + render() + end + local disbandAction = function() + sendCommand("remove_output_group", { id = device.id }) + end local actionText = device.active and tr("device.current") or (device.bluetooth and not device.available and tr("actions.connect_and_use") or tr("actions.use")) local actionVariant = device.active and "ghost" or "primary" local statusColor = device.active and "secondary" or "on_surface_variant" - local row = ui.column({ gap = 6 }, { - ui.row({ align = "center", gap = 8, padding = 7, radius = 9, border = device.active and "primary" or "outline/0.55", borderWidth = 1, fill = device.active and "primary/0.07" or "surface/0" }, { + local actions = {} + if groupingOutputs and kind == "output" then + table.insert(actions, ui.button({ + text = selectedGroupOutputs[device.id] and tr("actions.selected") or tr("actions.select"), + glyph = selectedGroupOutputs[device.id] and "check" or "plus", + variant = selectedGroupOutputs[device.id] and "primary" or "outline", + controlSize = "sm", + enabled = device.available == true and device.group ~= true and snapshot.busy ~= true, + onClick = selectAction, + })) + else + table.insert(actions, ui.button({ + text = actionText, + variant = actionVariant, + controlSize = "sm", + enabled = not device.active and snapshot.busy ~= true, + onClick = useAction, + })) + if kind == "output" and device.group then + table.insert(actions, ui.button({ + text = tr("actions.disband"), + glyph = "unlink", + variant = "outline", + controlSize = "sm", + enabled = snapshot.busy ~= true, + onClick = disbandAction, + })) + else + table.insert(actions, ui.button({ glyph = "edit", variant = "ghost", controlSize = "sm", tooltip = tr("actions.edit"), onClick = editAction })) + table.insert(actions, ui.button({ + glyph = device.hidden and "eye" or "eye-off", + variant = "ghost", + controlSize = "sm", + tooltip = tr(device.hidden and "actions.show" or "actions.hide"), + onClick = hideAction, + })) + end + end + local rowChildren = { ui.glyph({ name = device.icon or (kind == "output" and "volume" or "microphone"), size = 19, color = device.active and "primary" or "on_surface_variant" }), ui.column({ flexGrow = 1, gap = 2 }, { ui.label({ text = tostring(device.name or device.description or device.id), fontWeight = "bold", maxLines = 1 }), @@ -234,28 +288,48 @@ local function deviceRow(device, kind) fontWeight = "bold", visible = device.bluetooth == true, }), - ui.button({ - text = actionText, - variant = actionVariant, - controlSize = "sm", - enabled = not device.active and snapshot.busy ~= true, - onClick = useAction, - }), - ui.button({ glyph = "edit", variant = "ghost", controlSize = "sm", tooltip = tr("actions.edit"), onClick = editAction }), - ui.button({ - glyph = device.hidden and "eye" or "eye-off", - variant = "ghost", - controlSize = "sm", - tooltip = tr(device.hidden and "actions.show" or "actions.hide"), - onClick = hideAction, - }), - }), + } + for _, action in ipairs(actions) do table.insert(rowChildren, action) end + local row = ui.column({ gap = 6 }, { + ui.row({ align = "center", gap = 8, padding = 7, radius = 9, border = selectedGroupOutputs[device.id] and "primary" or (device.active and "primary" or "outline/0.55"), borderWidth = 1, fill = selectedGroupOutputs[device.id] and "primary/0.1" or (device.active and "primary/0.07" or "surface/0") }, rowChildren), }) local children = { row } if editingId == device.id then table.insert(children, editor(device)) end return ui.column({ gap = 6 }, children) end +local function groupingToolbar() + if activeTab ~= "output" then return ui.column({ visible = false }, {}) end + if not groupingOutputs then + return ui.row({ justify = "end" }, { + ui.button({ + text = tr("actions.group_outputs"), + glyph = "link", + variant = "outline", + enabled = snapshot.busy ~= true, + onClick = "onStartGrouping", + }), + }) + end + local count = selectedGroupCount() + return ui.row({ align = "center", gap = 8, padding = 8, radius = 9, fill = "primary/0.08" }, { + ui.label({ + text = tr("panel.group_hint", { count = count }), + flexGrow = 1, + color = "on_surface_variant", + fontSize = 11, + }), + ui.button({ text = tr("actions.cancel"), variant = "ghost", onClick = "onCancelGrouping" }), + ui.button({ + text = tr("actions.create_group", { count = count }), + glyph = "link", + variant = "primary", + enabled = count >= 2 and snapshot.busy ~= true, + onClick = "onCreateGroup", + }), + }) +end + local function deviceList(kind) local source = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs) local rows = {} @@ -357,6 +431,7 @@ render = function() header(), ui.row({ gap = 12, align = "stretch" }, { volumeCard("output"), volumeCard("input") }), toolbar(), + groupingToolbar(), ui.column({ gap = 3 }, statusRows), ui.scroll({ flexGrow = 1, gap = 6 }, { deviceList(activeTab) }), ui.label({ @@ -371,6 +446,8 @@ end function onOpen(_context) feedback = "" feedbackError = false + groupingOutputs = false + selectedGroupOutputs = {} sendCommand("refresh") render() end @@ -406,9 +483,35 @@ function onShowInputs() activeTab = "input" editingId = nil showHidden = false + groupingOutputs = false + selectedGroupOutputs = {} render() end +function onStartGrouping() + groupingOutputs = true + selectedGroupOutputs = {} + editingId = nil + showHidden = false + feedback = "" + render() +end + +function onCancelGrouping() + groupingOutputs = false + selectedGroupOutputs = {} + render() +end + +function onCreateGroup() + local ids = {} + for _, device in ipairs(asArray(snapshot.outputs)) do + if selectedGroupOutputs[device.id] then table.insert(ids, device.id) end + end + if #ids < 2 then return end + sendCommand("create_output_group", { ids = ids }) +end + function onToggleHidden() showHidden = not showHidden editingId = nil @@ -478,6 +581,10 @@ noctalia.state.watch(RESULT_KEY, function(result) if type(result) ~= "table" or not tostring(result.requestId or ""):match("^panel%-") then return end feedback = tostring(result.message or "") feedbackError = result.ok ~= true + if result.ok == true and result.action == "create_output_group" then + groupingOutputs = false + selectedGroupOutputs = {} + end if result.ok ~= true and (result.action == "set_output_volume" or result.action == "set_input_volume") then outputVolumeCommitValue = nil inputVolumeCommitValue = nil diff --git a/audio-switcher/plugin.toml b/audio-switcher/plugin.toml index fbef509..e38454b 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.2" +version = "0.3.0" plugin_api = 9 author = "blackbartblues" license = "MIT" diff --git a/audio-switcher/service.luau b/audio-switcher/service.luau index 933b368..4d7b21f 100644 --- a/audio-switcher/service.luau +++ b/audio-switcher/service.luau @@ -10,6 +10,8 @@ local PREFERENCES_VERSION = 1 local REFRESH_INTERVAL_MS = 3000 local BLUETOOTH_CONNECT_RETRIES = 16 local BLUETOOTH_CONNECT_RETRY_SECONDS = 0.25 +local GROUP_SINK_PREFIX = "noctalia_group_" +local GROUP_DESCRIPTION = "Noctalia_Output_Group" local dataDir = noctalia.pluginDataDir() local preferencesPath = dataDir and (dataDir .. "/preferences.json") or nil @@ -58,6 +60,8 @@ local volumeRefreshes = { input = { running = false, again = false }, } local audioEventRevisions = { output = 0, input = 0 } +local combineSinkOption = "slaves" +local groupNameCounter = 0 local refreshAll local setOutput @@ -236,13 +240,14 @@ local function bluetoothAddress(properties) return value:upper() end -local function makeOutput(item, defaultName) +local function makeOutput(item, defaultName, group) local properties = propertiesOf(item) local address = bluetoothAddress(properties) local isBluetooth = address ~= "" local stableId = isBluetooth and bluetoothOutputId(address) or tostring(item.name or "") local fallback = tostring(properties["device.alias"] or item.description or item.name or stableId) - local icon, iconStyle = resolvedDeviceIcon(stableId, isBluetooth and "headphones" or "device-speaker") + if group ~= nil then fallback = noctalia.tr("device.output_group") end + local icon, iconStyle = resolvedDeviceIcon(stableId, group ~= nil and "link" or (isBluetooth and "headphones" or "device-speaker")) return { id = stableId, targetName = tostring(item.name or ""), @@ -258,6 +263,9 @@ local function makeOutput(item, defaultName) muted = item.mute == true, icon = icon, iconStyle = iconStyle, + group = group ~= nil, + groupModuleId = group and group.moduleId or nil, + groupMembers = group and group.members or nil, } end @@ -304,16 +312,42 @@ local function parseInfo(output) return { defaultSink = tostring(decoded.default_sink_name or ""), defaultSource = tostring(decoded.default_source_name or ""), + serverName = tostring(decoded.server_name or ""), } end -local function parseOutputs(output, defaultName) +local function moduleArgumentValue(argument, key) + return tostring(argument or ""):match("%f[%w_]" .. key .. "=([^%s]+)") +end + +local function parseOutputGroups(output) + local groups = {} + for line in (tostring(output or "") .. "\n"):gmatch("(.-)\n") do + local moduleId, moduleName, argument = line:match("^(%d+)%s+([^%s]+)%s+(.-)%s*$") + if moduleName == "module-combine-sink" then + local sinkName = moduleArgumentValue(argument, "sink_name") + local membersValue = moduleArgumentValue(argument, "sinks") + or moduleArgumentValue(argument, "slaves") + if sinkName ~= nil and sinkName:sub(1, #GROUP_SINK_PREFIX) == GROUP_SINK_PREFIX then + local members = {} + for member in tostring(membersValue or ""):gmatch("[^,]+") do + table.insert(members, member) + end + groups[sinkName] = { moduleId = tonumber(moduleId), members = members } + end + end + end + return groups +end + +local function parseOutputs(output, defaultName, groups) local decoded = decodeJson(output) local devices = {} if decoded == nil then return devices end for _, item in ipairs(decoded) do if type(item) == "table" and trim(item.name) ~= "" then - table.insert(devices, makeOutput(item, defaultName)) + local name = tostring(item.name or "") + table.insert(devices, makeOutput(item, defaultName, groups and groups[name] or nil)) end end sortDevices(devices) @@ -586,59 +620,67 @@ local function loadAudio(callback) callback(false, noctalia.tr("errors.invalid_audio_data")) return end - runPactl({ "-f", "json", "list", "sinks" }, function(sinkResult) - if sinkResult.exitCode ~= 0 then - callback(false, trim(sinkResult.stderr)) + combineSinkOption = info.serverName:find("PipeWire", 1, true) ~= nil and "sinks" or "slaves" + runPactl({ "list", "short", "modules" }, function(moduleResult) + if moduleResult.exitCode ~= 0 then + callback(false, trim(moduleResult.stderr)) return end - runPactl({ "-f", "json", "list", "sources" }, function(sourceResult) - if sourceResult.exitCode ~= 0 then - callback(false, trim(sourceResult.stderr)) + local groups = parseOutputGroups(moduleResult.stdout) + runPactl({ "-f", "json", "list", "sinks" }, function(sinkResult) + if sinkResult.exitCode ~= 0 then + callback(false, trim(sinkResult.stderr)) return end - local currentOutputVolume = snapshot.outputVolume - local currentInputVolume = snapshot.inputVolume - local currentOutputMuted = snapshot.outputMuted - local currentInputMuted = snapshot.inputMuted - snapshot.outputs = parseOutputs(sinkResult.stdout, info.defaultSink) - snapshot.inputs = parseInputs(sourceResult.stdout, info.defaultSource) - snapshot.defaultOutputId = "" - snapshot.defaultInputId = "" - snapshot.outputVolume = 0 - snapshot.inputVolume = 0 - snapshot.outputMuted = false - snapshot.inputMuted = false - for _, output in ipairs(snapshot.outputs) do - if output.active then - snapshot.defaultOutputId = output.id - snapshot.outputVolume = output.volume - snapshot.outputMuted = output.muted - break + runPactl({ "-f", "json", "list", "sources" }, function(sourceResult) + if sourceResult.exitCode ~= 0 then + callback(false, trim(sourceResult.stderr)) + return end - end - for _, input in ipairs(snapshot.inputs) do - if input.active then - snapshot.defaultInputId = input.id - snapshot.inputVolume = input.volume - snapshot.inputMuted = input.muted - break + local currentOutputVolume = snapshot.outputVolume + local currentInputVolume = snapshot.inputVolume + local currentOutputMuted = snapshot.outputMuted + local currentInputMuted = snapshot.inputMuted + snapshot.outputs = parseOutputs(sinkResult.stdout, info.defaultSink, groups) + snapshot.inputs = parseInputs(sourceResult.stdout, info.defaultSource) + snapshot.defaultOutputId = "" + snapshot.defaultInputId = "" + snapshot.outputVolume = 0 + snapshot.inputVolume = 0 + snapshot.outputMuted = false + snapshot.inputMuted = false + for _, output in ipairs(snapshot.outputs) do + if output.active then + snapshot.defaultOutputId = output.id + snapshot.outputVolume = output.volume + snapshot.outputMuted = output.muted + break + end end - end - if audioEventRevisions.output ~= outputEventRevision then - snapshot.outputVolume = currentOutputVolume - snapshot.outputMuted = currentOutputMuted - else - snapshot.outputVolume = reconcileVolumeDisplay("output", snapshot.outputVolume) - end - if audioEventRevisions.input ~= inputEventRevision then - snapshot.inputVolume = currentInputVolume - snapshot.inputMuted = currentInputMuted - else - snapshot.inputVolume = reconcileVolumeDisplay("input", snapshot.inputVolume) - end - updateActiveDeviceVolume("output", snapshot.outputVolume, snapshot.outputMuted) - updateActiveDeviceVolume("input", snapshot.inputVolume, snapshot.inputMuted) - callback(true, "") + for _, input in ipairs(snapshot.inputs) do + if input.active then + snapshot.defaultInputId = input.id + snapshot.inputVolume = input.volume + snapshot.inputMuted = input.muted + break + end + end + if audioEventRevisions.output ~= outputEventRevision then + snapshot.outputVolume = currentOutputVolume + snapshot.outputMuted = currentOutputMuted + else + snapshot.outputVolume = reconcileVolumeDisplay("output", snapshot.outputVolume) + end + if audioEventRevisions.input ~= inputEventRevision then + snapshot.inputVolume = currentInputVolume + snapshot.inputMuted = currentInputMuted + else + snapshot.inputVolume = reconcileVolumeDisplay("input", snapshot.inputVolume) + end + updateActiveDeviceVolume("output", snapshot.outputVolume, snapshot.outputMuted) + updateActiveDeviceVolume("input", snapshot.inputVolume, snapshot.inputMuted) + callback(true, "") + end) end) end) end) @@ -774,6 +816,119 @@ local function setDefaultTarget(command, kind, device) end) end +local function uniqueGroupSinkName() + groupNameCounter += 1 + local base = GROUP_SINK_PREFIX .. tostring(os.time()) .. "_" .. tostring(groupNameCounter) + local candidate = base + local suffix = 1 + while findOutputById(candidate) ~= nil do + suffix += 1 + candidate = base .. "_" .. tostring(suffix) + end + return candidate +end + +local function createOutputGroup(command) + if actionBusy then + resultMessage(command, false, noctalia.tr("errors.busy")) + return + end + local selected = {} + local seen = {} + for _, id in ipairs(type(command.ids) == "table" and command.ids or {}) do + local device = findOutputById(tostring(id or "")) + if device ~= nil and device.available and not device.group and trim(device.targetName) ~= "" and not seen[device.id] then + seen[device.id] = true + table.insert(selected, device) + end + end + if #selected < 2 then + resultMessage(command, false, noctalia.tr("errors.group_requires_two")) + return + end + + local targetNames = {} + for _, device in ipairs(selected) do table.insert(targetNames, device.targetName) end + local sinkName = uniqueGroupSinkName() + actionBusy = true + publishSnapshot() + runPactl({ + "load-module", + "module-combine-sink", + "sink_name=" .. sinkName, + combineSinkOption .. "=" .. table.concat(targetNames, ","), + "sink_properties=device.description=" .. GROUP_DESCRIPTION, + }, function(loadResult) + local moduleId = tonumber(trim(loadResult.stdout)) + if loadResult.exitCode ~= 0 or moduleId == nil then + local message = trim(loadResult.stderr) + finishAction(command, false, message ~= "" and message or noctalia.tr("errors.group_create_failed")) + return + end + runPactl({ "set-default-sink", sinkName }, function(defaultResult) + if defaultResult.exitCode ~= 0 then + runPactl({ "unload-module", tostring(moduleId) }, function() + local message = trim(defaultResult.stderr) + finishAction(command, false, message ~= "" and message or noctalia.tr("errors.group_switch_failed")) + end) + return + end + moveStreams("output", sinkName, function() + finishAction(command, true, noctalia.tr("notifications.group_created")) + end) + end) + end) +end + +local function removeOutputGroup(command) + if actionBusy then + resultMessage(command, false, noctalia.tr("errors.busy")) + return + end + local group = findOutputById(tostring(command.id or "")) + if group == nil or not group.group or tonumber(group.groupModuleId) == nil then + resultMessage(command, false, noctalia.tr("errors.group_not_found")) + return + end + + local fallback = nil + for _, targetName in ipairs(type(group.groupMembers) == "table" and group.groupMembers or {}) do + for _, device in ipairs(snapshot.outputs) do + if not device.group and device.available and device.targetName == targetName then + fallback = device + break + end + end + if fallback ~= nil then break end + end + + actionBusy = true + publishSnapshot() + local function unloadGroup() + runPactl({ "unload-module", tostring(group.groupModuleId) }, function(result) + if result.exitCode ~= 0 then + local message = trim(result.stderr) + finishAction(command, false, message ~= "" and message or noctalia.tr("errors.group_remove_failed")) + return + end + finishAction(command, true, noctalia.tr("notifications.group_removed")) + end) + end + + if group.active and fallback ~= nil then + runPactl({ "set-default-sink", fallback.targetName }, function(result) + if result.exitCode ~= 0 then + local message = trim(result.stderr) + finishAction(command, false, message ~= "" and message or noctalia.tr("errors.group_switch_failed")) + return + end + moveStreams("output", fallback.targetName, unloadGroup) + end) + else + unloadGroup() + end +end + assignSlot = function(address) address = tostring(address or ""):upper() if address == "" or tonumber(preferences.slots[address]) ~= nil then return end @@ -1111,6 +1266,10 @@ local function executeCommand(command) setOutput(command, tostring(command.id or "")) elseif action == "set_input" then setInput(command, tostring(command.id or "")) + elseif action == "create_output_group" then + createOutputGroup(command) + elseif action == "remove_output_group" then + removeOutputGroup(command) elseif action == "cycle_output" then cycleDevice(command, "output") elseif action == "cycle_input" then diff --git a/audio-switcher/tests/mute_state_test.lua b/audio-switcher/tests/mute_state_test.lua index 1849399..87acd4f 100644 --- a/audio-switcher/tests/mute_state_test.lua +++ b/audio-switcher/tests/mute_state_test.lua @@ -14,6 +14,7 @@ local decoded = { INFO = { default_sink_name = "sink.main", default_source_name = "source.main", + server_name = "PulseAudio (on PipeWire 1.6.8)", }, SINKS_UNMUTED = { { @@ -106,6 +107,7 @@ assert(load(serviceSource, "@service.luau"))() assert(type(streamCallback) == "function", "pactl subscription was not started") complete("'info'", "INFO") +complete("'list' 'short' 'modules'", "") complete("'list' 'sinks'", "SINKS_UNMUTED") complete("'list' 'sources'", "SOURCES_UNMUTED") diff --git a/audio-switcher/tests/output_group_test.lua b/audio-switcher/tests/output_group_test.lua new file mode 100644 index 0000000..1d52c01 --- /dev/null +++ b/audio-switcher/tests/output_group_test.lua @@ -0,0 +1,200 @@ +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 decoded = { + INFO_PHYSICAL = { + default_sink_name = "sink.a", + default_source_name = "source.main", + server_name = "PulseAudio (on PipeWire 1.6.8)", + }, + INFO_GROUPED = { + default_sink_name = "noctalia_group_123_1", + default_source_name = "source.main", + server_name = "PulseAudio (on PipeWire 1.6.8)", + }, + SINKS_PHYSICAL = { + { + name = "sink.a", + description = "Speakers", + mute = false, + volume = { front_left = { value_percent = "40%" } }, + properties = {}, + }, + { + name = "sink.b", + description = "Headphones", + mute = false, + volume = { front_left = { value_percent = "40%" } }, + properties = {}, + }, + }, + SINKS_GROUPED = { + { + name = "sink.a", + description = "Speakers", + mute = false, + volume = { front_left = { value_percent = "40%" } }, + properties = {}, + }, + { + name = "sink.b", + description = "Headphones", + mute = false, + volume = { front_left = { value_percent = "40%" } }, + properties = {}, + }, + { + name = "noctalia_group_123_1", + description = "Noctalia Output Group", + mute = false, + volume = { front_left = { value_percent = "40%" } }, + properties = {}, + }, + }, + SOURCES = { + { + name = "source.main", + description = "Microphone", + mute = false, + volume = { front_left = { value_percent = "30%" } }, + properties = {}, + }, + }, + STREAMS_EMPTY = {}, +} + +local fakeTime = 123 + +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() 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 completeRefresh(info, modules, sinks) + complete("'info'", info) + complete("'list' 'short' 'modules'", modules) + complete("'list' 'sinks'", sinks) + complete("'list' 'sources'", "SOURCES") +end + +local originalTime = os.time +os.time = function() return fakeTime 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"))() + +completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL") + +noctalia.state.set("audio_switcher_command", { + requestId = "create-group", + action = "create_output_group", + ids = { "sink.a", "sink.b" }, +}) +assert(pending[1].command:find("'sinks=sink.a,sink.b'", 1, true), "PipeWire must use the sinks module option") +complete("'load-module' 'module-combine-sink'", "77\n") +complete("'set-default-sink' 'noctalia_group_123_1'") +complete("'list' 'sink-inputs'", "STREAMS_EMPTY") +completeRefresh( + "INFO_GROUPED", + "77\tmodule-combine-sink\tsink_name=noctalia_group_123_1 sinks=sink.a,sink.b sink_properties=device.description=Noctalia_Output_Group\t\n", + "SINKS_GROUPED" +) + +local snapshot = values["audio_switcher_snapshot"] +local group = nil +for _, output in ipairs(snapshot.outputs) do + if output.group then group = output end +end +assert(group ~= nil, "plugin-created combine sink was not detected") +assert(group.active == true, "new output group is not active") +assert(group.groupModuleId == 77, "module index was not retained") +assert(#group.groupMembers == 2, "group members were not parsed") + +noctalia.state.set("audio_switcher_command", { + requestId = "remove-group", + action = "remove_output_group", + id = group.id, +}) +complete("'set-default-sink' 'sink.a'") +complete("'list' 'sink-inputs'", "STREAMS_EMPTY") +complete("'unload-module' '77'") +completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL") + +snapshot = values["audio_switcher_snapshot"] +for _, output in ipairs(snapshot.outputs) do + assert(output.group ~= true, "removed group remained in the snapshot") +end + +decoded.INFO_PHYSICAL.server_name = "pulseaudio" +noctalia.state.set("audio_switcher_command", { + requestId = "refresh-pulseaudio", + action = "refresh", +}) +completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL") +noctalia.state.set("audio_switcher_command", { + requestId = "create-pulseaudio-group", + action = "create_output_group", + ids = { "sink.a", "sink.b" }, +}) +assert(pending[1].command:find("'slaves=sink.a,sink.b'", 1, true), "PulseAudio must use the slaves module option") +complete("'load-module' 'module-combine-sink'", "", 1) +completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL") + +assert(#pending == 0, "unexpected commands remain pending") + +os.time = originalTime +print("audio-switcher output group tests: ok") diff --git a/audio-switcher/translations/en.json b/audio-switcher/translations/en.json index 5ac4400..c326dc3 100644 --- a/audio-switcher/translations/en.json +++ b/audio-switcher/translations/en.json @@ -3,11 +3,16 @@ "cancel": "Cancel", "close": "Close", "connect_and_use": "Connect & use", + "create_group": "Create group ({count})", "cycle": "Cycle", + "disband": "Disband", "edit": "Edit device", + "group_outputs": "Group outputs", "hide": "Hide device", "refresh": "Refresh", "save": "Save", + "select": "Select", + "selected": "Selected", "settings": "Plugin settings", "show": "Show device", "use": "Use" @@ -17,7 +22,8 @@ "available": "Available", "bluetooth_connected": "Bluetooth connected", "bluetooth_disconnected": "Bluetooth disconnected", - "current": "Current" + "current": "Current", + "output_group": "Output group" }, "editor": { "alias": "Display name", @@ -43,6 +49,11 @@ "command_start": "Could not start the system command.", "device_not_found": "The selected device no longer exists.", "device_unavailable": "The selected device is not currently available.", + "group_create_failed": "Could not create the output group.", + "group_not_found": "The output group no longer exists.", + "group_remove_failed": "Could not disband the output group.", + "group_requires_two": "Select at least two available outputs.", + "group_switch_failed": "The output group was created, but could not be selected.", "invalid_audio_data": "The audio service returned invalid data.", "invalid_slot": "The keybind number must be a whole number from 1 to 99.", "invalid_volume": "The requested volume is invalid.", @@ -55,6 +66,8 @@ }, "notifications": { "bluetooth_disconnected": "Bluetooth device disconnected.", + "group_created": "Output group created and selected.", + "group_removed": "Output group disbanded.", "input_selected": "Input switched to {device}.", "output_selected": "Output switched to {device}.", "preferences_saved": "Device preferences saved.", @@ -62,6 +75,7 @@ "slot_saved": "Keybind number {slot} saved." }, "panel": { + "group_hint": "{count} selected. Choose at least two available outputs.", "hidden": "Hidden", "hidden_count": "Hidden {count}", "input_volume": "Microphone",