feat(audio-switcher): support input ports (#287)

This commit is contained in:
blacku
2026-08-07 22:35:39 -04:00
committed by GitHub
parent 9fd283bb52
commit 3467523e00
7 changed files with 217 additions and 9 deletions
+5
View File
@@ -40,6 +40,11 @@ or **Inputs**, then choose **Use**. For a disconnected Bluetooth output the same
button connects it, waits for its PipeWire endpoint, makes it the default, and button connects it, waits for its PipeWire endpoint, makes it the default, and
moves current playback streams to it. moves current playback streams to it.
When an input exposes multiple hardware ports, such as an internal microphone
and a headset microphone, the active port name is shown in the panel and
widget. Use the port selector below the input to switch it; **Cycle** also
includes every currently available port.
Use the pencil button to set a local display name, choose the device icon, and Use the pencil button to set a local display name, choose the device icon, and
change a Bluetooth keybind number. The number is assigned automatically after a 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
+39 -2
View File
@@ -73,6 +73,12 @@ local function activeDevice(kind)
return nil return nil
end end
local function inputDisplayName(device)
if device == nil then return "" end
local port = noctalia.string.trim(tostring(device.activePortDescription or ""))
return port ~= "" and port or tostring(device.name or "")
end
local function visibleCount(kind) local function visibleCount(kind)
local count = 0 local count = 0
local devices = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs) local devices = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs)
@@ -118,7 +124,7 @@ local function volumeCard(kind)
ui.column({ flexGrow = 1, gap = 2 }, { ui.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = tr(isOutput and "panel.output_volume" or "panel.input_volume"), fontWeight = "bold" }), ui.label({ text = tr(isOutput and "panel.output_volume" or "panel.input_volume"), fontWeight = "bold" }),
ui.label({ ui.label({
text = device and device.name or tr("panel.no_device"), text = device and (isOutput and device.name or inputDisplayName(device)) or tr("panel.no_device"),
fontSize = 11, fontSize = 11,
color = "on_surface_variant", color = "on_surface_variant",
maxLines = 1, maxLines = 1,
@@ -157,7 +163,10 @@ end
local function deviceSubtitle(device) local function deviceSubtitle(device)
local status = deviceStatus(device) local status = deviceStatus(device)
local description = noctalia.string.trim(tostring(device.description or "")) local description = noctalia.string.trim(tostring(device.description or ""))
local port = noctalia.string.trim(tostring(device.activePortDescription or ""))
if port ~= "" and port ~= device.name and port ~= description then status = status .. " · " .. port end
if description == "" or description == "(null)" or description:lower() == "null" then return status end if description == "" or description == "(null)" or description:lower() == "null" then return status end
if description == device.name or description == port then return status end
return status .. " · " .. description return status .. " · " .. description
end end
@@ -228,6 +237,21 @@ local function deviceRow(device, kind)
local disbandAction = function() local disbandAction = function()
sendCommand("remove_output_group", { id = device.id }) sendCommand("remove_output_group", { id = device.id })
end end
local ports = kind == "input" and asArray(device.ports) or {}
local portOptions = {}
local selectedPortIndex = 0
for index, port in ipairs(ports) do
local label = tostring(port.description or port.name or "")
if port.available == false then label = tr("device.unavailable_port", { port = label }) end
portOptions[#portOptions + 1] = label
if port.active then selectedPortIndex = index - 1 end
end
local selectPortAction = function(index, _label)
local port = ports[math.floor(tonumber(index) or -1) + 1]
if port ~= nil and not port.active then
sendCommand("set_input_port", { id = device.id, port = port.name })
end
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"
@@ -294,6 +318,18 @@ local function deviceRow(device, kind)
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), 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 } local children = { row }
if kind == "input" and #ports > 1 then
table.insert(children, ui.row({ gap = 8, align = "center", paddingH = 8 }, {
ui.label({ text = tr("device.port"), color = "on_surface_variant", fontSize = 10 }),
ui.select({
options = portOptions,
selectedIndex = selectedPortIndex,
flexGrow = 1,
enabled = snapshot.busy ~= true,
onChange = selectPortAction,
}),
}))
end
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
@@ -350,7 +386,8 @@ end
local function header() local function header()
local output = activeDevice("output") local output = activeDevice("output")
local input = activeDevice("input") local input = activeDevice("input")
local subtitle = (output and output.name or tr("panel.no_output")) .. " · " .. (input and input.name or tr("panel.no_input")) local subtitle = (output and output.name or tr("panel.no_output")) .. " · "
.. (input and inputDisplayName(input) or tr("panel.no_input"))
return ui.row({ align = "center", gap = 8 }, { return ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = "switch-horizontal", size = 24, color = "primary" }), ui.glyph({ name = "switch-horizontal", size = 24, color = "primary" }),
ui.column({ flexGrow = 1, gap = 2 }, { ui.column({ flexGrow = 1, gap = 2 }, {
+1 -1
View File
@@ -1,6 +1,6 @@
id = "blackbartblues/audio-switcher" id = "blackbartblues/audio-switcher"
name = "Audio Switcher" name = "Audio Switcher"
version = "0.4.0" version = "0.5.0"
plugin_api = 15 plugin_api = 15
author = "blackbartblues" author = "blackbartblues"
license = "MIT" license = "MIT"
+126 -4
View File
@@ -232,6 +232,34 @@ local function propertiesOf(item)
return type(item.properties) == "table" and item.properties or {} return type(item.properties) == "table" and item.properties or {}
end end
-- BEGIN INPUT PORT HELPERS
local function inputPortText(value)
return tostring(value or ""):match("^%s*(.-)%s*$") or ""
end
local function parseInputPorts(item)
local activeValue = type(item.active_port) == "table" and item.active_port.name or item.active_port
local activeName = inputPortText(activeValue)
local ports = {}
for _, port in ipairs(type(item.ports) == "table" and item.ports or {}) do
if type(port) == "table" then
local name = inputPortText(port.name)
if name ~= "" then
local description = inputPortText(port.description)
local availability = inputPortText(port.availability):lower()
ports[#ports + 1] = {
name = name,
description = description ~= "" and description or name,
available = availability ~= "not available",
active = name == activeName,
}
end
end
end
return ports, activeName
end
-- END INPUT PORT HELPERS
local function bluetoothAddress(properties) local function bluetoothAddress(properties)
local value = trim(properties["api.bluez5.address"]) local value = trim(properties["api.bluez5.address"])
if value == "" and properties["device.bus"] == "bluetooth" then if value == "" and properties["device.bus"] == "bluetooth" then
@@ -279,7 +307,16 @@ local function makeInput(item, defaultName)
local stableId = isBluetooth and bluetoothInputId(address) or tostring(item.name or "") local stableId = isBluetooth and bluetoothInputId(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)
if isBluetooth then setPreference(preferences.knownBluetoothInputs, address, fallback) end if isBluetooth then setPreference(preferences.knownBluetoothInputs, address, fallback) end
local icon, iconStyle = resolvedDeviceIcon(stableId, isBluetooth and "headset" or "microphone") local ports, activePortName = parseInputPorts(item)
local activePortDescription = ""
for _, port in ipairs(ports) do
if port.active then activePortDescription = port.description break end
end
local portLooksLikeHeadset = (activePortName .. " " .. activePortDescription):lower():find("headset", 1, true) ~= nil
local icon, iconStyle = resolvedDeviceIcon(
stableId,
isBluetooth and "headset" or (portLooksLikeHeadset and "headset" or "microphone")
)
return { return {
id = stableId, id = stableId,
targetName = tostring(item.name or ""), targetName = tostring(item.name or ""),
@@ -295,6 +332,9 @@ local function makeInput(item, defaultName)
muted = item.mute == true, muted = item.mute == true,
icon = icon, icon = icon,
iconStyle = iconStyle, iconStyle = iconStyle,
ports = ports,
activePort = activePortName,
activePortDescription = activePortDescription,
} }
end end
@@ -1051,14 +1091,87 @@ setInput = function(command, id)
setDefaultTarget(command, "input", device) setDefaultTarget(command, "input", device)
end end
local function findInputPort(device, portName)
for _, port in ipairs(type(device.ports) == "table" and device.ports or {}) do
if port.name == portName then return port end
end
return nil
end
local function setInputPort(command, id, portName)
if actionBusy then
resultMessage(command, false, noctalia.tr("errors.busy"))
return
end
local device = findInputById(id)
if device == nil then
finishAction(command, false, noctalia.tr("errors.device_not_found"))
return
end
local port = findInputPort(device, portName)
if port == nil then
finishAction(command, false, noctalia.tr("errors.input_port_not_found"))
return
end
if port.available == false then
finishAction(command, false, noctalia.tr("errors.input_port_unavailable"))
return
end
if trim(device.targetName) == "" then
finishAction(command, false, noctalia.tr("errors.device_unavailable"))
return
end
actionBusy = true
publishSnapshot()
runPactl({ "set-source-port", device.targetName, port.name }, function(portResult)
if portResult.exitCode ~= 0 then
local message = trim(portResult.stderr)
finishAction(command, false, message ~= "" and message or noctalia.tr("errors.input_port_switch_failed"))
return
end
runPactl({ "set-default-source", device.targetName }, function(defaultResult)
if defaultResult.exitCode ~= 0 then
local message = trim(defaultResult.stderr)
finishAction(command, false, message ~= "" and message or noctalia.tr("errors.switch_failed"))
return
end
moveStreams("input", device.targetName, function()
finishAction(
command,
true,
noctalia.tr("notifications.input_port_selected", { port = port.description }),
noctalia.getConfig("show_notification_on_switch")
)
end)
end)
end)
end
local function cycleDevice(command, kind) local function cycleDevice(command, kind)
local devices = kind == "output" and snapshot.outputs or snapshot.inputs local devices = kind == "output" and snapshot.outputs or snapshot.inputs
local visible = {} local visible = {}
local activeIndex = 0 local activeIndex = 0
for _, device in ipairs(devices) do for _, device in ipairs(devices) do
if not device.hidden and (kind == "output" or device.available) then if not device.hidden and (kind == "output" or device.available) then
table.insert(visible, device) local ports = kind == "input" and type(device.ports) == "table" and device.ports or {}
if device.active then activeIndex = #visible end if kind == "input" and #ports > 1 then
local addedPort = false
for _, port in ipairs(ports) do
if port.available ~= false then
table.insert(visible, { id = device.id, port = port.name })
addedPort = true
if device.active and port.active then activeIndex = #visible end
end
end
if not addedPort then
table.insert(visible, device)
if device.active then activeIndex = #visible end
end
else
table.insert(visible, device)
if device.active then activeIndex = #visible end
end
end end
end end
if #visible == 0 then if #visible == 0 then
@@ -1066,7 +1179,14 @@ local function cycleDevice(command, kind)
return return
end end
local nextIndex = activeIndex % #visible + 1 local nextIndex = activeIndex % #visible + 1
if kind == "output" then setOutput(command, visible[nextIndex].id) else setInput(command, visible[nextIndex].id) end local target = visible[nextIndex]
if kind == "output" then
setOutput(command, target.id)
elseif target.port ~= nil then
setInputPort(command, target.id, target.port)
else
setInput(command, target.id)
end
end end
local function findAddressBySlot(slot) local function findAddressBySlot(slot)
@@ -1266,6 +1386,8 @@ 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 == "set_input_port" then
setInputPort(command, tostring(command.id or ""), tostring(command.port or ""))
elseif action == "create_output_group" then elseif action == "create_output_group" then
createOutputGroup(command) createOutputGroup(command)
elseif action == "remove_output_group" then elseif action == "remove_output_group" then
+36
View File
@@ -0,0 +1,36 @@
local sourceFile = assert(io.open("service.luau", "rb"))
local source = sourceFile:read("*a")
sourceFile:close()
local beginMarker = "-- BEGIN INPUT PORT HELPERS"
local endMarker = "-- END INPUT PORT HELPERS"
local beginAt = assert(source:find(beginMarker, 1, true), "input port helper start marker missing")
local bodyAt = assert(source:find("\n", beginAt, true)) + 1
local endAt = assert(source:find(endMarker, bodyAt, true), "input port helper end marker missing")
local helperSource = source:sub(bodyAt, endAt - 1)
local loader = assert(load(helperSource .. "\nreturn parseInputPorts", "input port helpers", "t", _G))
local parseInputPorts = loader()
local ports, active = parseInputPorts({
active_port = "analog-input-internal-mic",
ports = {
{ name = "analog-input-internal-mic", description = "Internal Microphone", availability = "available" },
{ name = "analog-input-headset-mic", description = "Headset Microphone", availability = "not available" },
{ name = "analog-input-mic", description = "Microphone", availability = "unknown" },
},
})
assert(active == "analog-input-internal-mic")
assert(#ports == 3)
assert(ports[1].active and ports[1].available and ports[1].description == "Internal Microphone")
assert(not ports[2].active and not ports[2].available and ports[2].description == "Headset Microphone")
assert(ports[3].available, "unknown availability should remain selectable")
local objectPorts, objectActive = parseInputPorts({
active_port = { name = "mic" },
ports = { { name = "mic", description = "", availability = "available" } },
})
assert(objectActive == "mic" and objectPorts[1].active)
assert(objectPorts[1].description == "mic", "port name should be the description fallback")
print("audio input port tests: ok")
+7 -1
View File
@@ -23,7 +23,9 @@
"bluetooth_connected": "Bluetooth connected", "bluetooth_connected": "Bluetooth connected",
"bluetooth_disconnected": "Bluetooth disconnected", "bluetooth_disconnected": "Bluetooth disconnected",
"current": "Current", "current": "Current",
"output_group": "Output group" "output_group": "Output group",
"port": "Port",
"unavailable_port": "{port} (unavailable)"
}, },
"editor": { "editor": {
"alias": "Display name", "alias": "Display name",
@@ -57,6 +59,9 @@
"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.",
"input_port_not_found": "The selected input port no longer exists.",
"input_port_switch_failed": "Could not switch the input port.",
"input_port_unavailable": "The selected input port is not currently available.",
"no_visible_devices": "There are no visible devices to cycle through.", "no_visible_devices": "There are no visible devices to cycle through.",
"pactl_missing": "pactl is required but was not found on PATH.", "pactl_missing": "pactl is required but was not found on PATH.",
"scan_in_progress": "Bluetooth scanning is already in progress.", "scan_in_progress": "Bluetooth scanning is already in progress.",
@@ -68,6 +73,7 @@
"bluetooth_disconnected": "Bluetooth device disconnected.", "bluetooth_disconnected": "Bluetooth device disconnected.",
"group_created": "Output group created and selected.", "group_created": "Output group created and selected.",
"group_removed": "Output group disbanded.", "group_removed": "Output group disbanded.",
"input_port_selected": "Input port switched to {port}.",
"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.",
+3 -1
View File
@@ -27,7 +27,9 @@ local function render()
local outputDevice = activeDevice(snapshot.outputs) local outputDevice = activeDevice(snapshot.outputs)
local inputDevice = activeDevice(snapshot.inputs) local inputDevice = activeDevice(snapshot.inputs)
local outputName = outputDevice and tostring(outputDevice.name or "") or noctalia.tr("panel.no_output") local outputName = outputDevice and tostring(outputDevice.name or "") or noctalia.tr("panel.no_output")
local inputName = inputDevice and tostring(inputDevice.name or "") or noctalia.tr("panel.no_input") local inputPortName = inputDevice and tostring(inputDevice.activePortDescription or "") or ""
local inputName = inputDevice and (inputPortName ~= "" and inputPortName or tostring(inputDevice.name or ""))
or noctalia.tr("panel.no_input")
local outputVolume = percent(snapshot.outputVolume) local outputVolume = percent(snapshot.outputVolume)
local inputVolume = percent(snapshot.inputVolume) local inputVolume = percent(snapshot.inputVolume)