feat(audio-switcher): support grouped outputs (#163)
This commit is contained in:
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user