feat(audio-switcher): support input ports (#287)
This commit is contained in:
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -73,6 +73,12 @@ local function activeDevice(kind)
|
||||
return nil
|
||||
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 count = 0
|
||||
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.label({ text = tr(isOutput and "panel.output_volume" or "panel.input_volume"), fontWeight = "bold" }),
|
||||
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,
|
||||
color = "on_surface_variant",
|
||||
maxLines = 1,
|
||||
@@ -157,7 +163,10 @@ end
|
||||
local function deviceSubtitle(device)
|
||||
local status = deviceStatus(device)
|
||||
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 == device.name or description == port then return status end
|
||||
return status .. " · " .. description
|
||||
end
|
||||
|
||||
@@ -228,6 +237,21 @@ local function deviceRow(device, kind)
|
||||
local disbandAction = function()
|
||||
sendCommand("remove_output_group", { id = device.id })
|
||||
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")
|
||||
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"
|
||||
@@ -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),
|
||||
})
|
||||
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
|
||||
return ui.column({ gap = 6 }, children)
|
||||
end
|
||||
@@ -350,7 +386,8 @@ end
|
||||
local function header()
|
||||
local output = activeDevice("output")
|
||||
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 }, {
|
||||
ui.glyph({ name = "switch-horizontal", size = 24, color = "primary" }),
|
||||
ui.column({ flexGrow = 1, gap = 2 }, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
id = "blackbartblues/audio-switcher"
|
||||
name = "Audio Switcher"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
plugin_api = 15
|
||||
author = "blackbartblues"
|
||||
license = "MIT"
|
||||
|
||||
+126
-4
@@ -232,6 +232,34 @@ local function propertiesOf(item)
|
||||
return type(item.properties) == "table" and item.properties or {}
|
||||
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 value = trim(properties["api.bluez5.address"])
|
||||
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 fallback = tostring(properties["device.alias"] or item.description or item.name or stableId)
|
||||
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 {
|
||||
id = stableId,
|
||||
targetName = tostring(item.name or ""),
|
||||
@@ -295,6 +332,9 @@ local function makeInput(item, defaultName)
|
||||
muted = item.mute == true,
|
||||
icon = icon,
|
||||
iconStyle = iconStyle,
|
||||
ports = ports,
|
||||
activePort = activePortName,
|
||||
activePortDescription = activePortDescription,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -1051,14 +1091,87 @@ setInput = function(command, id)
|
||||
setDefaultTarget(command, "input", device)
|
||||
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 devices = kind == "output" and snapshot.outputs or snapshot.inputs
|
||||
local visible = {}
|
||||
local activeIndex = 0
|
||||
for _, device in ipairs(devices) do
|
||||
if not device.hidden and (kind == "output" or device.available) then
|
||||
table.insert(visible, device)
|
||||
if device.active then activeIndex = #visible end
|
||||
local ports = kind == "input" and type(device.ports) == "table" and device.ports or {}
|
||||
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
|
||||
if #visible == 0 then
|
||||
@@ -1066,7 +1179,14 @@ local function cycleDevice(command, kind)
|
||||
return
|
||||
end
|
||||
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
|
||||
|
||||
local function findAddressBySlot(slot)
|
||||
@@ -1266,6 +1386,8 @@ local function executeCommand(command)
|
||||
setOutput(command, tostring(command.id or ""))
|
||||
elseif action == "set_input" then
|
||||
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
|
||||
createOutputGroup(command)
|
||||
elseif action == "remove_output_group" then
|
||||
|
||||
@@ -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")
|
||||
@@ -23,7 +23,9 @@
|
||||
"bluetooth_connected": "Bluetooth connected",
|
||||
"bluetooth_disconnected": "Bluetooth disconnected",
|
||||
"current": "Current",
|
||||
"output_group": "Output group"
|
||||
"output_group": "Output group",
|
||||
"port": "Port",
|
||||
"unavailable_port": "{port} (unavailable)"
|
||||
},
|
||||
"editor": {
|
||||
"alias": "Display name",
|
||||
@@ -57,6 +59,9 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"pactl_missing": "pactl is required but was not found on PATH.",
|
||||
"scan_in_progress": "Bluetooth scanning is already in progress.",
|
||||
@@ -68,6 +73,7 @@
|
||||
"bluetooth_disconnected": "Bluetooth device disconnected.",
|
||||
"group_created": "Output group created and selected.",
|
||||
"group_removed": "Output group disbanded.",
|
||||
"input_port_selected": "Input port switched to {port}.",
|
||||
"input_selected": "Input switched to {device}.",
|
||||
"output_selected": "Output switched to {device}.",
|
||||
"preferences_saved": "Device preferences saved.",
|
||||
|
||||
@@ -27,7 +27,9 @@ local function render()
|
||||
local outputDevice = activeDevice(snapshot.outputs)
|
||||
local inputDevice = activeDevice(snapshot.inputs)
|
||||
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 inputVolume = percent(snapshot.inputVolume)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user