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 Switcher puts PipeWire inputs, outputs, volume controls, and Bluetooth
audio handoff in one compact Noctalia panel. Devices can be renamed or hidden, audio handoff in one compact Noctalia panel. Devices can be renamed or hidden,
and compositor keybinds can cycle devices or connect a specific Bluetooth multiple outputs can be grouped for simultaneous playback, and compositor
device by its persistent number. keybinds can cycle devices or connect a specific Bluetooth device by its
persistent number.
![Audio Switcher panel](screenshots/panel.webp) ![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 Install `pactl`, `bluetoothctl`, and `sleep` on `PATH`. On Arch Linux they are
provided by the `libpulse`, `bluez-utils`, and `coreutils` packages respectively. provided by the `libpulse`, `bluez-utils`, and `coreutils` packages respectively.
PipeWire's PulseAudio compatibility service and BlueZ must be running. PipeWire's PulseAudio compatibility service and BlueZ must be running.
Output grouping also requires PulseAudio's or PipeWire Pulse's
`module-combine-sink`.
## Usage ## 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 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. 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. Use the settings button in the panel header to open Noctalia's plugin settings.
## 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 the target from another computer or phone; that device must release the target
first unless it supports multipoint connections. 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 The plugin does not access the network. Its service spawns only the declared
`pactl`, `bluetoothctl`, and `sleep` commands. The short sleep is used while `pactl`, `bluetoothctl`, and `sleep` commands. The short sleep is used while
waiting for a newly connected Bluetooth endpoint to appear. The panel may also 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 feedback = ""
local feedbackError = false local feedbackError = false
local requestCounter = 0 local requestCounter = 0
local groupingOutputs = false
local selectedGroupOutputs = {}
-- Slider onChange is also emitted when the host applies a new controlled value. -- 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 -- 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. -- the first external update into a sticky local draft and freeze later updates.
@@ -89,6 +91,14 @@ local function hiddenCount(kind)
return count return count
end 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) local function selectedIndex(values, selected)
for index, value in ipairs(values) do for index, value in ipairs(values) do
if value == selected then return index - 1 end if value == selected then return index - 1 end
@@ -211,12 +221,56 @@ local function deviceRow(device, kind)
local hideAction = function() local hideAction = function()
sendCommand("set_hidden", { id = device.id, kind = kind, hidden = not device.hidden }) sendCommand("set_hidden", { id = device.id, kind = kind, hidden = not device.hidden })
end 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") 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")) 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 actionVariant = device.active and "ghost" or "primary"
local statusColor = device.active and "secondary" or "on_surface_variant" local statusColor = device.active and "secondary" or "on_surface_variant"
local row = ui.column({ gap = 6 }, { local actions = {}
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" }, { 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.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.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = tostring(device.name or device.description or device.id), fontWeight = "bold", maxLines = 1 }), 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", fontWeight = "bold",
visible = device.bluetooth == true, visible = device.bluetooth == true,
}), }),
ui.button({ }
text = actionText, for _, action in ipairs(actions) do table.insert(rowChildren, action) end
variant = actionVariant, local row = ui.column({ gap = 6 }, {
controlSize = "sm", 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),
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,
}),
}),
}) })
local children = { row } local children = { row }
if editingId == device.id then table.insert(children, editor(device)) end if editingId == device.id then table.insert(children, editor(device)) end
return ui.column({ gap = 6 }, children) return ui.column({ gap = 6 }, children)
end 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 function deviceList(kind)
local source = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs) local source = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs)
local rows = {} local rows = {}
@@ -357,6 +431,7 @@ render = function()
header(), header(),
ui.row({ gap = 12, align = "stretch" }, { volumeCard("output"), volumeCard("input") }), ui.row({ gap = 12, align = "stretch" }, { volumeCard("output"), volumeCard("input") }),
toolbar(), toolbar(),
groupingToolbar(),
ui.column({ gap = 3 }, statusRows), ui.column({ gap = 3 }, statusRows),
ui.scroll({ flexGrow = 1, gap = 6 }, { deviceList(activeTab) }), ui.scroll({ flexGrow = 1, gap = 6 }, { deviceList(activeTab) }),
ui.label({ ui.label({
@@ -371,6 +446,8 @@ end
function onOpen(_context) function onOpen(_context)
feedback = "" feedback = ""
feedbackError = false feedbackError = false
groupingOutputs = false
selectedGroupOutputs = {}
sendCommand("refresh") sendCommand("refresh")
render() render()
end end
@@ -406,9 +483,35 @@ function onShowInputs()
activeTab = "input" activeTab = "input"
editingId = nil editingId = nil
showHidden = false showHidden = false
groupingOutputs = false
selectedGroupOutputs = {}
render() render()
end 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() function onToggleHidden()
showHidden = not showHidden showHidden = not showHidden
editingId = nil 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 if type(result) ~= "table" or not tostring(result.requestId or ""):match("^panel%-") then return end
feedback = tostring(result.message or "") feedback = tostring(result.message or "")
feedbackError = result.ok ~= true 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 if result.ok ~= true and (result.action == "set_output_volume" or result.action == "set_input_volume") then
outputVolumeCommitValue = nil outputVolumeCommitValue = nil
inputVolumeCommitValue = nil inputVolumeCommitValue = nil
+1 -1
View File
@@ -1,6 +1,6 @@
id = "blackbartblues/audio-switcher" id = "blackbartblues/audio-switcher"
name = "Audio Switcher" name = "Audio Switcher"
version = "0.2.2" version = "0.3.0"
plugin_api = 9 plugin_api = 9
author = "blackbartblues" author = "blackbartblues"
license = "MIT" license = "MIT"
+210 -51
View File
@@ -10,6 +10,8 @@ local PREFERENCES_VERSION = 1
local REFRESH_INTERVAL_MS = 3000 local REFRESH_INTERVAL_MS = 3000
local BLUETOOTH_CONNECT_RETRIES = 16 local BLUETOOTH_CONNECT_RETRIES = 16
local BLUETOOTH_CONNECT_RETRY_SECONDS = 0.25 local BLUETOOTH_CONNECT_RETRY_SECONDS = 0.25
local GROUP_SINK_PREFIX = "noctalia_group_"
local GROUP_DESCRIPTION = "Noctalia_Output_Group"
local dataDir = noctalia.pluginDataDir() local dataDir = noctalia.pluginDataDir()
local preferencesPath = dataDir and (dataDir .. "/preferences.json") or nil local preferencesPath = dataDir and (dataDir .. "/preferences.json") or nil
@@ -58,6 +60,8 @@ local volumeRefreshes = {
input = { running = false, again = false }, input = { running = false, again = false },
} }
local audioEventRevisions = { output = 0, input = 0 } local audioEventRevisions = { output = 0, input = 0 }
local combineSinkOption = "slaves"
local groupNameCounter = 0
local refreshAll local refreshAll
local setOutput local setOutput
@@ -236,13 +240,14 @@ local function bluetoothAddress(properties)
return value:upper() return value:upper()
end end
local function makeOutput(item, defaultName) local function makeOutput(item, defaultName, group)
local properties = propertiesOf(item) local properties = propertiesOf(item)
local address = bluetoothAddress(properties) local address = bluetoothAddress(properties)
local isBluetooth = address ~= "" local isBluetooth = address ~= ""
local stableId = isBluetooth and bluetoothOutputId(address) or tostring(item.name or "") 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 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 { return {
id = stableId, id = stableId,
targetName = tostring(item.name or ""), targetName = tostring(item.name or ""),
@@ -258,6 +263,9 @@ local function makeOutput(item, defaultName)
muted = item.mute == true, muted = item.mute == true,
icon = icon, icon = icon,
iconStyle = iconStyle, iconStyle = iconStyle,
group = group ~= nil,
groupModuleId = group and group.moduleId or nil,
groupMembers = group and group.members or nil,
} }
end end
@@ -304,16 +312,42 @@ local function parseInfo(output)
return { return {
defaultSink = tostring(decoded.default_sink_name or ""), defaultSink = tostring(decoded.default_sink_name or ""),
defaultSource = tostring(decoded.default_source_name or ""), defaultSource = tostring(decoded.default_source_name or ""),
serverName = tostring(decoded.server_name or ""),
} }
end 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 decoded = decodeJson(output)
local devices = {} local devices = {}
if decoded == nil then return devices end if decoded == nil then return devices end
for _, item in ipairs(decoded) do for _, item in ipairs(decoded) do
if type(item) == "table" and trim(item.name) ~= "" then 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
end end
sortDevices(devices) sortDevices(devices)
@@ -586,59 +620,67 @@ local function loadAudio(callback)
callback(false, noctalia.tr("errors.invalid_audio_data")) callback(false, noctalia.tr("errors.invalid_audio_data"))
return return
end end
runPactl({ "-f", "json", "list", "sinks" }, function(sinkResult) combineSinkOption = info.serverName:find("PipeWire", 1, true) ~= nil and "sinks" or "slaves"
if sinkResult.exitCode ~= 0 then runPactl({ "list", "short", "modules" }, function(moduleResult)
callback(false, trim(sinkResult.stderr)) if moduleResult.exitCode ~= 0 then
callback(false, trim(moduleResult.stderr))
return return
end end
runPactl({ "-f", "json", "list", "sources" }, function(sourceResult) local groups = parseOutputGroups(moduleResult.stdout)
if sourceResult.exitCode ~= 0 then runPactl({ "-f", "json", "list", "sinks" }, function(sinkResult)
callback(false, trim(sourceResult.stderr)) if sinkResult.exitCode ~= 0 then
callback(false, trim(sinkResult.stderr))
return return
end end
local currentOutputVolume = snapshot.outputVolume runPactl({ "-f", "json", "list", "sources" }, function(sourceResult)
local currentInputVolume = snapshot.inputVolume if sourceResult.exitCode ~= 0 then
local currentOutputMuted = snapshot.outputMuted callback(false, trim(sourceResult.stderr))
local currentInputMuted = snapshot.inputMuted return
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
end end
end local currentOutputVolume = snapshot.outputVolume
for _, input in ipairs(snapshot.inputs) do local currentInputVolume = snapshot.inputVolume
if input.active then local currentOutputMuted = snapshot.outputMuted
snapshot.defaultInputId = input.id local currentInputMuted = snapshot.inputMuted
snapshot.inputVolume = input.volume snapshot.outputs = parseOutputs(sinkResult.stdout, info.defaultSink, groups)
snapshot.inputMuted = input.muted snapshot.inputs = parseInputs(sourceResult.stdout, info.defaultSource)
break 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
end for _, input in ipairs(snapshot.inputs) do
if audioEventRevisions.output ~= outputEventRevision then if input.active then
snapshot.outputVolume = currentOutputVolume snapshot.defaultInputId = input.id
snapshot.outputMuted = currentOutputMuted snapshot.inputVolume = input.volume
else snapshot.inputMuted = input.muted
snapshot.outputVolume = reconcileVolumeDisplay("output", snapshot.outputVolume) break
end end
if audioEventRevisions.input ~= inputEventRevision then end
snapshot.inputVolume = currentInputVolume if audioEventRevisions.output ~= outputEventRevision then
snapshot.inputMuted = currentInputMuted snapshot.outputVolume = currentOutputVolume
else snapshot.outputMuted = currentOutputMuted
snapshot.inputVolume = reconcileVolumeDisplay("input", snapshot.inputVolume) else
end snapshot.outputVolume = reconcileVolumeDisplay("output", snapshot.outputVolume)
updateActiveDeviceVolume("output", snapshot.outputVolume, snapshot.outputMuted) end
updateActiveDeviceVolume("input", snapshot.inputVolume, snapshot.inputMuted) if audioEventRevisions.input ~= inputEventRevision then
callback(true, "") 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) end)
end) end)
@@ -774,6 +816,119 @@ local function setDefaultTarget(command, kind, device)
end) end)
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) assignSlot = function(address)
address = tostring(address or ""):upper() address = tostring(address or ""):upper()
if address == "" or tonumber(preferences.slots[address]) ~= nil then return end 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 "")) setOutput(command, tostring(command.id or ""))
elseif action == "set_input" then elseif action == "set_input" then
setInput(command, tostring(command.id or "")) 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 elseif action == "cycle_output" then
cycleDevice(command, "output") cycleDevice(command, "output")
elseif action == "cycle_input" then elseif action == "cycle_input" then
+2
View File
@@ -14,6 +14,7 @@ local decoded = {
INFO = { INFO = {
default_sink_name = "sink.main", default_sink_name = "sink.main",
default_source_name = "source.main", default_source_name = "source.main",
server_name = "PulseAudio (on PipeWire 1.6.8)",
}, },
SINKS_UNMUTED = { SINKS_UNMUTED = {
{ {
@@ -106,6 +107,7 @@ assert(load(serviceSource, "@service.luau"))()
assert(type(streamCallback) == "function", "pactl subscription was not started") assert(type(streamCallback) == "function", "pactl subscription was not started")
complete("'info'", "INFO") complete("'info'", "INFO")
complete("'list' 'short' 'modules'", "")
complete("'list' 'sinks'", "SINKS_UNMUTED") complete("'list' 'sinks'", "SINKS_UNMUTED")
complete("'list' 'sources'", "SOURCES_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", "cancel": "Cancel",
"close": "Close", "close": "Close",
"connect_and_use": "Connect & use", "connect_and_use": "Connect & use",
"create_group": "Create group ({count})",
"cycle": "Cycle", "cycle": "Cycle",
"disband": "Disband",
"edit": "Edit device", "edit": "Edit device",
"group_outputs": "Group outputs",
"hide": "Hide device", "hide": "Hide device",
"refresh": "Refresh", "refresh": "Refresh",
"save": "Save", "save": "Save",
"select": "Select",
"selected": "Selected",
"settings": "Plugin settings", "settings": "Plugin settings",
"show": "Show device", "show": "Show device",
"use": "Use" "use": "Use"
@@ -17,7 +22,8 @@
"available": "Available", "available": "Available",
"bluetooth_connected": "Bluetooth connected", "bluetooth_connected": "Bluetooth connected",
"bluetooth_disconnected": "Bluetooth disconnected", "bluetooth_disconnected": "Bluetooth disconnected",
"current": "Current" "current": "Current",
"output_group": "Output group"
}, },
"editor": { "editor": {
"alias": "Display name", "alias": "Display name",
@@ -43,6 +49,11 @@
"command_start": "Could not start the system command.", "command_start": "Could not start the system command.",
"device_not_found": "The selected device no longer exists.", "device_not_found": "The selected device no longer exists.",
"device_unavailable": "The selected device is not currently available.", "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_audio_data": "The audio service returned invalid data.",
"invalid_slot": "The keybind number must be a whole number from 1 to 99.", "invalid_slot": "The keybind number must be a whole number from 1 to 99.",
"invalid_volume": "The requested volume is invalid.", "invalid_volume": "The requested volume is invalid.",
@@ -55,6 +66,8 @@
}, },
"notifications": { "notifications": {
"bluetooth_disconnected": "Bluetooth device disconnected.", "bluetooth_disconnected": "Bluetooth device disconnected.",
"group_created": "Output group created and selected.",
"group_removed": "Output group disbanded.",
"input_selected": "Input switched to {device}.", "input_selected": "Input switched to {device}.",
"output_selected": "Output switched to {device}.", "output_selected": "Output switched to {device}.",
"preferences_saved": "Device preferences saved.", "preferences_saved": "Device preferences saved.",
@@ -62,6 +75,7 @@
"slot_saved": "Keybind number {slot} saved." "slot_saved": "Keybind number {slot} saved."
}, },
"panel": { "panel": {
"group_hint": "{count} selected. Choose at least two available outputs.",
"hidden": "Hidden", "hidden": "Hidden",
"hidden_count": "Hidden {count}", "hidden_count": "Hidden {count}",
"input_volume": "Microphone", "input_volume": "Microphone",