diff --git a/audio-switcher/README.md b/audio-switcher/README.md index ddd71a6..e53a4a5 100644 --- a/audio-switcher/README.md +++ b/audio-switcher/README.md @@ -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 diff --git a/audio-switcher/panel.luau b/audio-switcher/panel.luau index 9236bfc..81c2fdb 100644 --- a/audio-switcher/panel.luau +++ b/audio-switcher/panel.luau @@ -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 }, { diff --git a/audio-switcher/plugin.toml b/audio-switcher/plugin.toml index 714a8ef..b4bd9a3 100644 --- a/audio-switcher/plugin.toml +++ b/audio-switcher/plugin.toml @@ -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" diff --git a/audio-switcher/service.luau b/audio-switcher/service.luau index 4d7b21f..d83da90 100644 --- a/audio-switcher/service.luau +++ b/audio-switcher/service.luau @@ -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 diff --git a/audio-switcher/tests/input_ports_test.lua b/audio-switcher/tests/input_ports_test.lua new file mode 100644 index 0000000..a94c321 --- /dev/null +++ b/audio-switcher/tests/input_ports_test.lua @@ -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") diff --git a/audio-switcher/translations/en.json b/audio-switcher/translations/en.json index c326dc3..5ce2424 100644 --- a/audio-switcher/translations/en.json +++ b/audio-switcher/translations/en.json @@ -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.", diff --git a/audio-switcher/widget.luau b/audio-switcher/widget.luau index aef94f2..22900c4 100644 --- a/audio-switcher/widget.luau +++ b/audio-switcher/widget.luau @@ -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)