feat(audio-switcher): support grouped outputs (#163)

This commit is contained in:
blacku
2026-07-30 21:32:18 -04:00
committed by GitHub
parent 04f05125d9
commit aea6327c06
7 changed files with 570 additions and 73 deletions
+17 -2
View File
@@ -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
+125 -18
View File
@@ -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
+1 -1
View File
@@ -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"
+210 -51
View File
@@ -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
+2
View File
@@ -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")
+200
View File
@@ -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")
+15 -1
View File
@@ -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",