diff --git a/audio-switcher/README.md b/audio-switcher/README.md new file mode 100644 index 0000000..e368f33 --- /dev/null +++ b/audio-switcher/README.md @@ -0,0 +1,102 @@ +# Audio Switcher + +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. + +![Audio Switcher panel](screenshots/panel.webp) + +Add the **Audio Switcher** widget from Noctalia's bar editor. Left click opens +the panel, right click cycles through visible outputs, and middle click cycles +through visible inputs. Scrolling over it changes the output volume. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `blackbartblues/audio-switcher` | +| Entries | Widget: `widget`; panel: `audio-switcher`; service: `service` | + +## Requirements + +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. + +## Usage + +Open the panel from a configured bar widget or run: + +```sh +noctalia msg panel-toggle blackbartblues/audio-switcher:audio-switcher +``` + +The top sliders control the default output and input volume. Select **Outputs** +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. + +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 +devices remain available in the panel but are skipped by cycling commands. + +Use the settings button in the panel header to open Noctalia's plugin settings. + +## Settings + +| Entry | Setting | Type | Default | Description | +| --- | --- | --- | --- | --- | +| Plugin | `show_percentage` | `bool` | `true` | Show the output volume beside the bar icon; disable it for an icon-only widget. | +| Plugin | `scroll_step` | `int` | `5` | Volume points changed by each wheel step over the bar widget (1–25). | + +## IPC and keybinds + +The background service exposes commands that can be used by any compositor: + +```sh +# Next non-hidden output. Disconnected Bluetooth outputs are connected as needed. +noctalia msg plugin blackbartblues/audio-switcher:service all cycle-output + +# Next non-hidden, currently available input. +noctalia msg plugin blackbartblues/audio-switcher:service all cycle-input + +# Connect the Bluetooth device assigned to number 2 and use its output. +noctalia msg plugin blackbartblues/audio-switcher:service all connect 2 + +# Refresh device state. +noctalia msg plugin blackbartblues/audio-switcher:service all refresh +``` + +For example, Niri bindings can spawn the commands directly: + +```kdl +Mod+F9 { spawn "noctalia" "msg" "plugin" "blackbartblues/audio-switcher:service" "all" "cycle-output"; } +Mod+F10 { spawn "noctalia" "msg" "plugin" "blackbartblues/audio-switcher:service" "all" "cycle-input"; } +Mod+1 { spawn "noctalia" "msg" "plugin" "blackbartblues/audio-switcher:service" "all" "connect" "1"; } +``` + +Equivalent Hyprland bindings: + +```ini +bind = SUPER, F9, exec, noctalia msg plugin blackbartblues/audio-switcher:service all cycle-output +bind = SUPER, F10, exec, noctalia msg plugin blackbartblues/audio-switcher:service all cycle-input +bind = SUPER, 1, exec, noctalia msg plugin blackbartblues/audio-switcher:service all connect 1 +``` + +## Notes + +Preferences are written to `preferences.json` in Noctalia's data directory for +this plugin. They contain only aliases, hidden flags, remembered Bluetooth input +capabilities, MAC addresses, keybind numbers, and per-device icon choices. + +Before connecting a requested Bluetooth audio device, Audio Switcher disconnects +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. + +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 +invoke the local Noctalia executable to open the plugin settings page. diff --git a/audio-switcher/panel.luau b/audio-switcher/panel.luau new file mode 100644 index 0000000..0cfdb53 --- /dev/null +++ b/audio-switcher/panel.luau @@ -0,0 +1,501 @@ +--!nonstrict + +local SNAPSHOT_KEY = "audio_switcher_snapshot" +local COMMAND_KEY = "audio_switcher_command" +local RESULT_KEY = "audio_switcher_result" + +local snapshot = noctalia.state.get(SNAPSHOT_KEY) or { + available = false, + bluetoothAvailable = false, + loading = true, + busy = false, + scanning = false, + outputs = {}, + inputs = {}, + defaultOutputId = "", + defaultInputId = "", + outputVolume = 0, + inputVolume = 0, + outputMuted = false, + inputMuted = false, + error = "", + updatedAt = 0, +} + +local activeTab = "output" +local showHidden = false +local editingId = nil +local editAlias = "" +local editIconStyle = "automatic" +local editSlot = "" +local feedback = "" +local feedbackError = false +local requestCounter = 0 +-- 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. +local outputVolumeCommitValue = nil +local inputVolumeCommitValue = nil +local volumeControlRevision = 0 +local env = getfenv() +local render + +local ICON_STYLE_VALUES = { "automatic", "speaker", "over_ear", "tws", "wired" } + +local function tr(key, substitutions) + return noctalia.tr(key, substitutions) +end + +local function asArray(value) + return type(value) == "table" and value or {} +end + +local function nextRequestId() + requestCounter += 1 + return "panel-" .. tostring(requestCounter) +end + +local function sendCommand(action, values) + local command = { action = action, requestId = nextRequestId() } + if type(values) == "table" then + for key, value in pairs(values) do command[key] = value end + end + noctalia.state.set(COMMAND_KEY, command) + return command.requestId +end + +local function rowCallback(prefix, index, callback) + local name = prefix .. "_" .. tostring(index) + env[name] = callback + return name +end + +local function activeDevice(kind) + local devices = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs) + for _, device in ipairs(devices) do + if device.active then return device end + end + return nil +end + +local function visibleCount(kind) + local count = 0 + local devices = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs) + for _, device in ipairs(devices) do + if not device.hidden then count += 1 end + end + return count +end + +local function hiddenCount(kind) + local count = 0 + local devices = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs) + for _, device in ipairs(devices) do + if device.hidden 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 + end + return 0 +end + +local function volumeCard(kind) + local isOutput = kind == "output" + local device = activeDevice(kind) + local volume = tonumber(isOutput and snapshot.outputVolume or snapshot.inputVolume) or 0 + local muted = isOutput and snapshot.outputMuted == true or snapshot.inputMuted == true + local icon = isOutput and (muted and "volume-off" or "volume") or (muted and "microphone-mute" or "microphone") + return ui.column({ flexGrow = 1, gap = 8, padding = 12, radius = 12, fill = "surface_variant/0.45" }, { + ui.row({ align = "center", gap = 8 }, { + ui.glyph({ name = isOutput and "volume" or "microphone", size = 18, color = isOutput and "primary" or "tertiary" }), + 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"), + fontSize = 11, + color = "on_surface_variant", + maxLines = 1, + }), + }), + ui.label({ text = tostring(volume) .. "%", fontSize = 11, color = "on_surface_variant" }), + ui.button({ + glyph = icon, + variant = muted and "destructive" or "ghost", + controlSize = "sm", + tooltip = tr(muted and "panel.unmute" or "panel.mute"), + enabled = device ~= nil, + onClick = isOutput and "onToggleOutputMute" or "onToggleInputMute", + }), + }), + ui.slider({ + key = "volume-" .. kind .. "-" .. tostring(volumeControlRevision), + min = 0, + max = 100, + step = 1, + value = volume, + enabled = device ~= nil, + onChange = isOutput and "onOutputVolumeChange" or "onInputVolumeChange", + onDragEnd = isOutput and "onOutputVolumeCommit" or "onInputVolumeCommit", + }), + }) +end + +local function deviceStatus(device) + if device.active then return tr("device.active") end + if device.bluetooth and not device.available then return tr("device.bluetooth_disconnected") end + if device.bluetooth then return tr("device.bluetooth_connected") end + return tr("device.available") +end + +local function deviceSubtitle(device) + local status = deviceStatus(device) + local description = noctalia.string.trim(tostring(device.description or "")) + if description == "" or description == "(null)" or description:lower() == "null" then return status end + return status .. " · " .. description +end + +local function editor(device) + local children = { + ui.row({ align = "center", gap = 8 }, { + ui.glyph({ name = "edit", size = 16, color = "primary" }), + ui.label({ text = tr("editor.title"), fontWeight = "bold", flexGrow = 1 }), + ui.button({ glyph = "close", variant = "ghost", controlSize = "sm", onClick = "onCancelEdit" }), + }), + ui.label({ text = tr("editor.alias"), fontSize = 11, color = "on_surface_variant" }), + ui.input({ + key = "alias-" .. tostring(device.id), + value = editAlias, + placeholder = device.description, + onChange = "onEditAliasChange", + }), + ui.label({ text = tr("editor.icon"), fontSize = 11, color = "on_surface_variant" }), + ui.select({ + options = { + tr("editor.icon_options.automatic"), + tr("editor.icon_options.speaker"), + tr("editor.icon_options.over_ear"), + tr("editor.icon_options.tws"), + tr("editor.icon_options.wired"), + }, + selectedIndex = selectedIndex(ICON_STYLE_VALUES, editIconStyle), + width = 220, + onChange = "onEditIconChange", + }), + } + if device.bluetooth then + table.insert(children, ui.label({ text = tr("editor.slot"), fontSize = 11, color = "on_surface_variant" })) + table.insert(children, ui.input({ + key = "slot-" .. tostring(device.id), + value = editSlot, + placeholder = "1", + onChange = "onEditSlotChange", + })) + table.insert(children, ui.label({ text = tr("editor.slot_hint"), fontSize = 10, color = "on_surface_variant", maxLines = 2 })) + end + table.insert(children, ui.row({ justify = "end", gap = 8 }, { + ui.button({ text = tr("actions.cancel"), variant = "ghost", onClick = "onCancelEdit" }), + ui.button({ text = tr("actions.save"), glyph = "device-floppy", variant = "primary", onClick = "onSaveEdit" }), + })) + return ui.column({ gap = 7, padding = 10, radius = 9, fill = "surface_variant/0.45", border = "primary/0.35", borderWidth = 1 }, children) +end + +local function deviceRow(device, index, kind) + local useAction = rowCallback("onUse", index, function() + sendCommand(kind == "output" and "set_output" or "set_input", { id = device.id }) + end) + local editAction = rowCallback("onEdit", index, function() + editingId = device.id + editAlias = tostring(device.name or "") + editIconStyle = tostring(device.iconStyle or "automatic") + editSlot = device.slot and tostring(device.slot) or "" + feedback = "" + render() + end) + local hideAction = rowCallback("onHide", index, function() + sendCommand("set_hidden", { id = device.id, kind = kind, hidden = not device.hidden }) + 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" }, { + 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 }), + ui.label({ + text = deviceSubtitle(device), + fontSize = 10, + color = statusColor, + maxLines = 1, + }), + }), + ui.label({ + text = device.slot and ("#" .. tostring(device.slot)) or "", + color = "primary", + fontSize = 11, + 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, + }), + }), + }) + local children = { row } + if editingId == device.id then table.insert(children, editor(device)) end + return ui.column({ gap = 6 }, children) +end + +local function deviceList(kind) + local source = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs) + local rows = {} + local callbackIndex = kind == "output" and 0 or 1000 + for _, device in ipairs(source) do + if showHidden or not device.hidden then + table.insert(rows, deviceRow(device, callbackIndex, kind)) + callbackIndex += 1 + end + end + if #rows == 0 then + table.insert(rows, ui.column({ align = "center", justify = "center", gap = 8, padding = 24 }, { + ui.glyph({ name = kind == "output" and "volume-off" or "microphone-off", size = 32, color = "on_surface_variant" }), + ui.label({ text = tr(showHidden and "panel.no_devices" or "panel.no_visible_devices"), color = "on_surface_variant", textAlign = "center" }), + })) + end + return ui.column({ gap = 6 }, rows) +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")) + return ui.row({ align = "center", gap = 8 }, { + ui.glyph({ name = "switch-horizontal", size = 24, color = "primary" }), + ui.column({ flexGrow = 1, gap = 2 }, { + ui.label({ text = tr("title"), fontSize = 16, fontWeight = "bold" }), + ui.label({ text = subtitle, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), + }), + ui.button({ + glyph = "bluetooth", + variant = snapshot.scanning and "primary" or "ghost", + tooltip = tr(snapshot.scanning and "panel.scanning" or "panel.scan_bluetooth"), + enabled = snapshot.bluetoothAvailable == true and snapshot.scanning ~= true, + onClick = "onScanBluetooth", + }), + ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("actions.refresh"), onClick = "onRefresh" }), + ui.button({ glyph = "settings", variant = "ghost", tooltip = tr("actions.settings"), onClick = "onOpenSettingsClicked" }), + ui.button({ glyph = "close", variant = "ghost", tooltip = tr("actions.close"), onClick = "onCloseClicked" }), + }) +end + +local function toolbar() + local kind = activeTab + local hidden = hiddenCount(kind) + return ui.row({ align = "center", gap = 4 }, { + ui.button({ + text = tr("tabs.outputs"), + glyph = "volume", + variant = activeTab == "output" and "primary" or "ghost", + selected = activeTab == "output", + onClick = "onShowOutputs", + }), + ui.button({ + text = tr("tabs.inputs"), + glyph = "microphone", + variant = activeTab == "input" and "primary" or "ghost", + selected = activeTab == "input", + onClick = "onShowInputs", + }), + ui.spacer({ flexGrow = 1 }), + ui.label({ + text = tr("panel.visible_count", { count = visibleCount(kind) }), + fontSize = 10, + color = "on_surface_variant", + }), + ui.button({ + text = hidden > 0 and tr("panel.hidden_count", { count = hidden }) or tr("panel.hidden"), + glyph = showHidden and "eye" or "eye-off", + variant = showHidden and "primary" or "ghost", + selected = showHidden, + enabled = hidden > 0 or showHidden, + onClick = "onToggleHidden", + }), + ui.button({ + text = tr("actions.cycle"), + glyph = "refresh", + variant = "outline", + enabled = snapshot.busy ~= true, + onClick = activeTab == "output" and "onCycleOutput" or "onCycleInput", + }), + }) +end + +render = function() + local statusRows = {} + if snapshot.loading then + table.insert(statusRows, ui.label({ text = tr("panel.loading"), color = "primary" })) + end + if snapshot.busy then + table.insert(statusRows, ui.label({ text = tr("panel.switching"), color = "primary" })) + end + if tostring(snapshot.error or "") ~= "" then + table.insert(statusRows, ui.label({ text = snapshot.error, color = "error", maxLines = 2 })) + end + if feedback ~= "" then + table.insert(statusRows, ui.label({ text = feedback, color = feedbackError and "error" or "secondary", maxLines = 2 })) + end + + panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, { + header(), + ui.row({ gap = 12, align = "stretch" }, { volumeCard("output"), volumeCard("input") }), + toolbar(), + ui.column({ gap = 3 }, statusRows), + ui.scroll({ flexGrow = 1, gap = 6 }, { deviceList(activeTab) }), + ui.label({ + text = tr("panel.keybind_hint"), + fontSize = 10, + color = "on_surface_variant", + maxLines = 2, + }), + })) +end + +function onOpen(_context) + feedback = "" + feedbackError = false + sendCommand("refresh") + render() +end + +function onConfigChanged() + render() +end + +function onCloseClicked() panel.close() end +function onRefresh() sendCommand("refresh") end +function onScanBluetooth() sendCommand("scan_bluetooth") end +function onCycleOutput() sendCommand("cycle_output") end +function onCycleInput() sendCommand("cycle_input") end +function onOpenSettingsClicked() + local started = noctalia.runAsync("noctalia msg settings-open plugins", function(result) + if result.timedOut == true or tonumber(result.exitCode) ~= 0 then + noctalia.notifyError(tr("title"), tr("errors.command_start")) + end + end, 5000) + if not started then + noctalia.notifyError(tr("title"), tr("errors.command_start")) + end +end + +function onShowOutputs() + activeTab = "output" + editingId = nil + showHidden = false + render() +end + +function onShowInputs() + activeTab = "input" + editingId = nil + showHidden = false + render() +end + +function onToggleHidden() + showHidden = not showHidden + editingId = nil + render() +end + +function onCancelEdit() + editingId = nil + render() +end + +function onEditAliasChange(value) editAlias = tostring(value or "") end +function onEditIconChange(index, _label) + editIconStyle = ICON_STYLE_VALUES[(math.floor(tonumber(index) or 0)) + 1] or "automatic" +end +function onEditSlotChange(value) editSlot = tostring(value or "") end + +function onSaveEdit() + local devices = activeTab == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs) + for _, device in ipairs(devices) do + if device.id == editingId then + sendCommand("update_device", { + id = device.id, + address = device.address, + alias = editAlias, + iconStyle = editIconStyle, + slot = editSlot, + }) + editingId = nil + render() + return + end + end +end + +function onOutputVolumeChange(value) + outputVolumeCommitValue = math.floor(tonumber(value) or 0) +end + +function onInputVolumeChange(value) + inputVolumeCommitValue = math.floor(tonumber(value) or 0) +end + +-- Noctalia's slider onDragEnd callback has no arguments. Keep the latest +-- onChange value for the commit without using it as controlled render state. +function onOutputVolumeCommit() + local value = outputVolumeCommitValue or snapshot.outputVolume + outputVolumeCommitValue = nil + sendCommand("set_output_volume", { value = value }) +end +function onInputVolumeCommit() + local value = inputVolumeCommitValue or snapshot.inputVolume + inputVolumeCommitValue = nil + sendCommand("set_input_volume", { value = value }) +end +function onToggleOutputMute() sendCommand("toggle_output_mute") end +function onToggleInputMute() sendCommand("toggle_input_mute") end + +noctalia.state.watch(SNAPSHOT_KEY, function(value) + if type(value) == "table" then + snapshot = value + render() + end +end) + +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 == "set_output_volume" or result.action == "set_input_volume") then + outputVolumeCommitValue = nil + inputVolumeCommitValue = nil + -- A failed optimistic edit may have moved the native slider while the + -- controlled snapshot stayed unchanged. Recreate both controls so their + -- visual values are seeded from the authoritative snapshot again. + volumeControlRevision += 1 + end + render() +end) + +render() diff --git a/audio-switcher/plugin.toml b/audio-switcher/plugin.toml new file mode 100644 index 0000000..901233b --- /dev/null +++ b/audio-switcher/plugin.toml @@ -0,0 +1,44 @@ +id = "blackbartblues/audio-switcher" +name = "Audio Switcher" +version = "0.1.0" +plugin_api = 5 +author = "blackbartblues" +license = "MIT" +icon = "switch-horizontal" +description = "Switch audio inputs and outputs, hand off Bluetooth devices, and control them from compositor keybinds." +tags = ["audio", "bar", "hardware", "panel", "service", "system", "utility"] +dependencies = ["pactl", "bluetoothctl", "sleep"] + +[[setting]] +key = "show_percentage" +type = "bool" +label_key = "settings.show_percentage.label" +description_key = "settings.show_percentage.description" +default = true + +[[setting]] +key = "scroll_step" +type = "int" +label_key = "settings.scroll_step.label" +description_key = "settings.scroll_step.description" +default = 5 +min = 1 +max = 25 +step = 1 + +[[widget]] +id = "widget" +entry = "widget.luau" + +[[panel]] +id = "audio-switcher" +entry = "panel.luau" +width = 620 +height = 650 +placement = "attached" +position = "top_right" +open_near_click = true + +[[service]] +id = "service" +entry = "service.luau" diff --git a/audio-switcher/screenshots/panel.webp b/audio-switcher/screenshots/panel.webp new file mode 100644 index 0000000..014e861 Binary files /dev/null and b/audio-switcher/screenshots/panel.webp differ diff --git a/audio-switcher/service.luau b/audio-switcher/service.luau new file mode 100644 index 0000000..b7867c6 --- /dev/null +++ b/audio-switcher/service.luau @@ -0,0 +1,1160 @@ +--!nonstrict +-- Audio Switcher backend. The service is the single owner of pactl and +-- bluetoothctl subprocesses; panels and compositor keybinds talk to it through +-- shared state and onIpc(). + +local SNAPSHOT_KEY = "audio_switcher_snapshot" +local COMMAND_KEY = "audio_switcher_command" +local RESULT_KEY = "audio_switcher_result" +local PREFERENCES_VERSION = 1 +local REFRESH_INTERVAL_MS = 3000 +local BLUETOOTH_CONNECT_RETRIES = 16 +local BLUETOOTH_CONNECT_RETRY_SECONDS = 0.25 + +local dataDir = noctalia.pluginDataDir() +local preferencesPath = dataDir and (dataDir .. "/preferences.json") or nil + +local preferences = { + version = PREFERENCES_VERSION, + aliases = {}, + hiddenOutputs = {}, + hiddenInputs = {}, + slots = {}, + knownBluetoothInputs = {}, + icons = {}, +} + +local snapshot = { + available = false, + bluetoothAvailable = false, + loading = true, + busy = false, + scanning = false, + outputs = {}, + inputs = {}, + bluetooth = {}, + defaultOutputId = "", + defaultInputId = "", + outputVolume = 0, + inputVolume = 0, + outputMuted = false, + inputMuted = false, + error = "", + updatedAt = 0, + revision = 0, +} + +local refreshPending = false +local refreshAgain = false +local actionBusy = false +local scanning = false +local preferencesDirty = false +local volumeOperations = { + output = { running = false, pending = nil, desiredValue = nil }, + input = { running = false, pending = nil, desiredValue = nil }, +} +local volumeRefreshes = { + output = { running = false, again = false }, + input = { running = false, again = false }, +} +local audioEventRevisions = { output = 0, input = 0 } + +local refreshAll +local setOutput +local setInput +local connectBluetooth +local assignSlot + +local function trim(value) + return noctalia.string.trim(tostring(value or "")) +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function shellCommand(args) + local quoted = {} + for _, value in ipairs(args) do + table.insert(quoted, shellQuote(value)) + end + return table.concat(quoted, " ") +end + +local function runCommand(args, callback, timeoutMs) + local started = noctalia.runAsync(shellCommand(args), callback, timeoutMs or 15000) + if not started and type(callback) == "function" then + callback({ + exitCode = -1, + stdout = "", + stderr = noctalia.tr("errors.command_start"), + timedOut = false, + stdoutTruncated = false, + stderrTruncated = false, + }) + end + return started +end + +local function runPactl(args, callback, timeoutMs) + local command = { "pactl" } + for _, value in ipairs(args) do table.insert(command, value) end + return runCommand(command, callback, timeoutMs) +end + +local function runBluetoothctl(args, callback, timeoutMs) + local command = { "bluetoothctl" } + for _, value in ipairs(args) do table.insert(command, value) end + return runCommand(command, callback, timeoutMs or 30000) +end + +local function decodeJson(value) + local decoded, err = noctalia.json.decode(tostring(value or "")) + if type(decoded) ~= "table" then + noctalia.log("audio-switcher: JSON decode failed: " .. tostring(err or "unknown error")) + return nil + end + return decoded +end + +local function normalizeMap(value) + return type(value) == "table" and value or {} +end + +local function setPreference(map, key, value) + if map[key] == value then return false end + map[key] = value + preferencesDirty = true + return true +end + +local function validIconStyle(value) + value = tostring(value or "automatic") + if value == "speaker" or value == "over_ear" or value == "tws" or value == "wired" then return value end + return "automatic" +end + +local function loadPreferences() + if preferencesPath == nil then return end + local raw = noctalia.readFile(preferencesPath) + if raw == nil then return end + local decoded = decodeJson(raw) + if decoded == nil then return end + preferences.version = PREFERENCES_VERSION + preferences.aliases = normalizeMap(decoded.aliases) + preferences.hiddenOutputs = normalizeMap(decoded.hiddenOutputs) + preferences.hiddenInputs = normalizeMap(decoded.hiddenInputs) + preferences.slots = normalizeMap(decoded.slots) + preferences.knownBluetoothInputs = normalizeMap(decoded.knownBluetoothInputs) + preferences.icons = normalizeMap(decoded.icons) +end + +local function savePreferences() + if not preferencesDirty then return true end + if preferencesPath == nil then return false end + local encoded, encodeError = noctalia.json.encode(preferences, true) + if encoded == nil then + noctalia.log("audio-switcher: could not encode preferences: " .. tostring(encodeError or "unknown error")) + return false + end + local ok, writeError = noctalia.writeFile(preferencesPath, encoded) + if not ok then + noctalia.log("audio-switcher: could not save preferences: " .. tostring(writeError or "unknown error")) + else + preferencesDirty = false + end + return ok == true +end + +local function bluetoothOutputId(address) + return "bluetooth:" .. address:upper() .. ":output" +end + +local function bluetoothInputId(address) + return "bluetooth:" .. address:upper() .. ":input" +end + +local function bluetoothAddressFromId(id) + return tostring(id or ""):match("^bluetooth:([%x:]+):") +end + +local function displayName(id, fallback) + local alias = trim(preferences.aliases[id]) + return alias ~= "" and alias or fallback +end + +local function resolvedDeviceIcon(id, automaticIcon) + local style = validIconStyle(preferences.icons[id]) + local glyphs = { + speaker = "device-speaker", + over_ear = "headphones", + tws = "device-airpods", + wired = "headset", + } + return glyphs[style] or automaticIcon, style +end + +local function firstVolumePercent(volume) + if type(volume) ~= "table" then return 0 end + for _, channel in pairs(volume) do + if type(channel) == "table" then + local percent = tostring(channel.value_percent or ""):match("(%d+)%%") + if percent ~= nil then return tonumber(percent) or 0 end + end + end + return 0 +end + +local function propertiesOf(item) + return type(item.properties) == "table" and item.properties or {} +end + +local function bluetoothAddress(properties) + local value = trim(properties["api.bluez5.address"]) + if value == "" and properties["device.bus"] == "bluetooth" then + value = trim(properties["device.string"]) + end + return value:upper() +end + +local function makeOutput(item, defaultName) + 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") + return { + id = stableId, + targetName = tostring(item.name or ""), + name = displayName(stableId, fallback), + description = tostring(item.description or item.name or ""), + active = tostring(item.name or "") == defaultName, + available = true, + bluetooth = isBluetooth, + address = address, + hidden = preferences.hiddenOutputs[stableId] == true, + slot = isBluetooth and tonumber(preferences.slots[address]) or nil, + volume = firstVolumePercent(item.volume), + muted = item.mute == true, + icon = icon, + iconStyle = iconStyle, + } +end + +local function makeInput(item, defaultName) + local properties = propertiesOf(item) + if tostring(properties["device.class"] or "") == "monitor" or tostring(item.name or ""):match("%.monitor$") then + return nil + end + local address = bluetoothAddress(properties) + local isBluetooth = address ~= "" + 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") + return { + id = stableId, + targetName = tostring(item.name or ""), + name = displayName(stableId, fallback), + description = tostring(item.description or item.name or ""), + active = tostring(item.name or "") == defaultName, + available = true, + bluetooth = isBluetooth, + address = address, + hidden = preferences.hiddenInputs[stableId] == true, + slot = isBluetooth and tonumber(preferences.slots[address]) or nil, + volume = firstVolumePercent(item.volume), + muted = item.mute == true, + icon = icon, + iconStyle = iconStyle, + } +end + +local function sortDevices(devices) + table.sort(devices, function(a, b) + if a.active ~= b.active then return a.active end + if a.available ~= b.available then return a.available end + return tostring(a.name):lower() < tostring(b.name):lower() + end) +end + +local function parseInfo(output) + local decoded = decodeJson(output) + if decoded == nil then return nil end + return { + defaultSink = tostring(decoded.default_sink_name or ""), + defaultSource = tostring(decoded.default_source_name or ""), + } +end + +local function parseOutputs(output, defaultName) + 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)) + end + end + sortDevices(devices) + return devices +end + +local function parseInputs(output, defaultName) + 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 + local device = makeInput(item, defaultName) + if device ~= nil then table.insert(devices, device) end + end + end + sortDevices(devices) + return devices +end + +local function parseBluetoothDeviceLines(output) + local devices = {} + for line in (tostring(output or "") .. "\n"):gmatch("(.-)\n") do + local address, name = line:match("^Device%s+([%x:]+)%s+(.+)$") + if address ~= nil then + table.insert(devices, { address = address:upper(), name = trim(name) }) + end + end + return devices +end + +local function parseYes(value) + return tostring(value or ""):lower() == "yes" +end + +local function parseBluetoothInfo(seed, output) + local device = { + id = bluetoothOutputId(seed.address), + address = seed.address, + name = seed.name, + description = seed.name, + icon = "bluetooth", + paired = false, + trusted = false, + connected = false, + audio = false, + battery = nil, + slot = tonumber(preferences.slots[seed.address]), + hidden = preferences.hiddenOutputs[bluetoothOutputId(seed.address)] == true, + } + for line in (tostring(output or "") .. "\n"):gmatch("(.-)\n") do + local key, value = line:match("^%s*([^:]+):%s*(.-)%s*$") + if key == "Alias" and value ~= "" then + device.description = value + device.name = displayName(device.id, value) + elseif key == "Icon" then + device.icon = value:find("headset", 1, true) and "headset" or value:find("headphone", 1, true) and "headphones" or "device-speaker" + if value:find("audio", 1, true) then device.audio = true end + elseif key == "Paired" then + device.paired = parseYes(value) + elseif key == "Trusted" then + device.trusted = parseYes(value) + elseif key == "Connected" then + device.connected = parseYes(value) + elseif key == "Battery Percentage" then + device.battery = tonumber(value:match("%((%d+)%)") or value:match("(%d+)") or "") + end + local uuid = line:match("%(([%x%-]+)%)") + if uuid ~= nil then + uuid = uuid:lower() + if uuid:find("0000110b", 1, true) + or uuid:find("0000110a", 1, true) + or uuid:find("0000111e", 1, true) + or uuid:find("0000184e", 1, true) + or uuid:find("00001850", 1, true) + or uuid:find("00001853", 1, true) then + device.audio = true + end + end + end + device.icon, device.iconStyle = resolvedDeviceIcon(device.id, device.icon) + return device +end + +local function findOutputById(id) + for _, device in ipairs(snapshot.outputs) do + if device.id == id then return device end + end + return nil +end + +local function findInputById(id) + for _, device in ipairs(snapshot.inputs) do + if device.id == id then return device end + end + return nil +end + +local function findBluetooth(address) + address = tostring(address or ""):upper() + for _, device in ipairs(snapshot.bluetooth) do + if device.address == address then return device end + end + return nil +end + +local function mergeDisconnectedBluetoothOutputs() + local present = {} + for _, output in ipairs(snapshot.outputs) do + if output.address ~= "" then + present[output.address] = true + output.slot = tonumber(preferences.slots[output.address]) + end + end + for _, input in ipairs(snapshot.inputs) do + if input.address ~= "" then input.slot = tonumber(preferences.slots[input.address]) end + end + for _, device in ipairs(snapshot.bluetooth) do + if device.audio and not present[device.address] then + local id = bluetoothOutputId(device.address) + table.insert(snapshot.outputs, { + id = id, + targetName = "", + name = displayName(id, device.description), + description = device.description, + active = false, + available = false, + bluetooth = true, + address = device.address, + hidden = preferences.hiddenOutputs[id] == true, + slot = tonumber(preferences.slots[device.address]), + volume = 0, + muted = false, + icon = device.icon, + iconStyle = device.iconStyle, + }) + end + end + sortDevices(snapshot.outputs) +end + +local function publishSnapshot() + snapshot.busy = actionBusy + snapshot.scanning = scanning + snapshot.revision += 1 + noctalia.state.set(SNAPSHOT_KEY, snapshot) +end + +local function resultMessage(command, ok, message) + noctalia.state.set(RESULT_KEY, { + requestId = tostring(command and command.requestId or ""), + action = tostring(command and command.action or ""), + ok = ok, + message = message or "", + }) +end + +local function notifyResult(ok, message) + if trim(message) == "" then return end + if ok then + noctalia.notify(noctalia.tr("title"), message) + else + noctalia.notifyError(noctalia.tr("title"), message) + end +end + +local function finishAction(command, ok, message, shouldNotify) + actionBusy = false + resultMessage(command, ok, message) + if shouldNotify ~= false then notifyResult(ok, message) end + publishSnapshot() + refreshAll() +end + +local function launchOrFail(args, callback, timeoutMs) + return runPactl(args, callback, timeoutMs) +end + +local function reconcileVolumeDisplay(kind, actualValue) + local operation = volumeOperations[kind] + local desired = tonumber(operation.desiredValue) + if desired == nil then return actualValue end + + -- A pactl list started before the latest wheel request can complete after it + -- and report an intermediate value. Keep showing the newest request until a + -- refresh made after the command has settled confirms it. + if not operation.running and operation.pending == nil and math.abs(actualValue - desired) <= 1 then + operation.desiredValue = nil + return actualValue + end + return desired +end + +local function updateActiveDeviceVolume(kind, volume, muted) + local devices = kind == "output" and snapshot.outputs or snapshot.inputs + for _, device in ipairs(devices) do + if device.active then + device.volume = volume + device.muted = muted + return + end + end +end + +local function loadCurrentVolume(kind, callback) + local target = kind == "output" and "@DEFAULT_SINK@" or "@DEFAULT_SOURCE@" + local volumeCommand = kind == "output" and "get-sink-volume" or "get-source-volume" + local muteCommand = kind == "output" and "get-sink-mute" or "get-source-mute" + runPactl({ "-f", "json", volumeCommand, target }, function(volumeResult) + if volumeResult.exitCode ~= 0 then + callback(false) + return + end + local volumeData = decodeJson(volumeResult.stdout) + if volumeData == nil or type(volumeData.volume) ~= "table" then + callback(false) + return + end + local volume = firstVolumePercent(volumeData.volume) + runPactl({ "-f", "json", muteCommand, target }, function(muteResult) + if muteResult.exitCode ~= 0 then + callback(false) + return + end + local muteData = decodeJson(muteResult.stdout) + if muteData == nil then + callback(false) + return + end + callback(true, volume, muteData.mute == true) + end) + end) +end + +local function refreshCurrentVolume(kind) + local state = volumeRefreshes[kind] + if state.running then + state.again = true + return + end + state.running = true + state.again = false + loadCurrentVolume(kind, function(ok, actualVolume, muted) + if ok then + local volume = reconcileVolumeDisplay(kind, actualVolume) + if kind == "output" then + snapshot.outputVolume = volume + snapshot.outputMuted = muted + else + snapshot.inputVolume = volume + snapshot.inputMuted = muted + end + updateActiveDeviceVolume(kind, volume, muted) + publishSnapshot() + end + state.running = false + if state.again then refreshCurrentVolume(kind) end + end) +end + +local function loadAudio(callback) + local outputEventRevision = audioEventRevisions.output + local inputEventRevision = audioEventRevisions.input + runPactl({ "-f", "json", "info" }, function(infoResult) + if infoResult.exitCode ~= 0 then + callback(false, trim(infoResult.stderr)) + return + end + local info = parseInfo(infoResult.stdout) + if info == nil then + 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)) + return + end + runPactl({ "-f", "json", "list", "sources" }, function(sourceResult) + if sourceResult.exitCode ~= 0 then + callback(false, trim(sourceResult.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 + 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 + 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 + +local function loadBluetooth(callback) + if not noctalia.commandExists("bluetoothctl") then + snapshot.bluetoothAvailable = false + snapshot.bluetooth = {} + callback() + return + end + runBluetoothctl({ "devices" }, function(listResult) + if listResult.exitCode ~= 0 then + snapshot.bluetoothAvailable = false + snapshot.bluetooth = {} + callback() + return + end + snapshot.bluetoothAvailable = true + local seeds = parseBluetoothDeviceLines(listResult.stdout) + local devices = {} + local index = 1 + local function nextDevice() + local seed = seeds[index] + if seed == nil then + local audioDevices = {} + for _, device in ipairs(devices) do + if device.audio then table.insert(audioDevices, device) end + end + table.sort(audioDevices, function(a, b) + if a.connected ~= b.connected then return a.connected end + if a.paired ~= b.paired then return a.paired end + return a.name:lower() < b.name:lower() + end) + for _, device in ipairs(audioDevices) do + if device.connected then + assignSlot(device.address) + device.slot = tonumber(preferences.slots[device.address]) + end + end + snapshot.bluetooth = audioDevices + callback() + return + end + index += 1 + runBluetoothctl({ "info", seed.address }, function(infoResult) + if infoResult.exitCode == 0 then + table.insert(devices, parseBluetoothInfo(seed, infoResult.stdout)) + end + nextDevice() + end) + end + nextDevice() + end) +end + +refreshAll = function() + if refreshPending then + refreshAgain = true + return + end + refreshPending = true + refreshAgain = false + snapshot.loading = #snapshot.outputs == 0 and #snapshot.inputs == 0 + publishSnapshot() + + if not noctalia.commandExists("pactl") then + snapshot.available = false + snapshot.loading = false + snapshot.error = noctalia.tr("errors.pactl_missing") + refreshPending = false + publishSnapshot() + return + end + + loadAudio(function(audioOk, audioError) + snapshot.available = audioOk + snapshot.error = audioOk and "" or (trim(audioError) ~= "" and audioError or noctalia.tr("errors.audio_unavailable")) + loadBluetooth(function() + mergeDisconnectedBluetoothOutputs() + snapshot.loading = false + snapshot.updatedAt = os.time() + refreshPending = false + savePreferences() + publishSnapshot() + if refreshAgain then refreshAll() end + end) + end) +end + +local function moveStreams(kind, targetName, callback) + local listKind = kind == "output" and "sink-inputs" or "source-outputs" + local moveCommand = kind == "output" and "move-sink-input" or "move-source-output" + runPactl({ "-f", "json", "list", listKind }, function(result) + local streams = result.exitCode == 0 and decodeJson(result.stdout) or nil + if type(streams) ~= "table" or #streams == 0 then + callback() + return + end + local pending = 0 + for _, stream in ipairs(streams) do + local index = tonumber(stream.index) + if index ~= nil then + pending += 1 + runPactl({ moveCommand, tostring(index), targetName }, function() + pending -= 1 + if pending == 0 then callback() end + end) + end + end + if pending == 0 then callback() end + end) +end + +local function setDefaultTarget(command, kind, device) + local setCommand = kind == "output" and "set-default-sink" or "set-default-source" + if device == nil or trim(device.targetName) == "" then + finishAction(command, false, noctalia.tr("errors.device_unavailable")) + return + end + actionBusy = true + publishSnapshot() + launchOrFail({ setCommand, device.targetName }, function(result) + if result.exitCode ~= 0 then + finishAction(command, false, trim(result.stderr) ~= "" and trim(result.stderr) or noctalia.tr("errors.switch_failed")) + return + end + moveStreams(kind, device.targetName, function() + local messageKey = kind == "output" and "notifications.output_selected" or "notifications.input_selected" + finishAction(command, true, noctalia.tr(messageKey, { device = device.name })) + end) + end) +end + +assignSlot = function(address) + address = tostring(address or ""):upper() + if address == "" or tonumber(preferences.slots[address]) ~= nil then return end + local used = {} + for _, value in pairs(preferences.slots) do + local number = tonumber(value) + if number ~= nil then used[number] = true end + end + local slot = 1 + while used[slot] do slot += 1 end + setPreference(preferences.slots, address, slot) + savePreferences() +end + +local function waitForBluetoothOutput(command, address, attempt) + runPactl({ "-f", "json", "list", "sinks" }, function(result) + if result.exitCode == 0 then + local devices = parseOutputs(result.stdout, "") + for _, device in ipairs(devices) do + if device.address == address then + setDefaultTarget(command, "output", device) + return + end + end + end + if attempt >= BLUETOOTH_CONNECT_RETRIES then + finishAction(command, false, noctalia.tr("errors.bluetooth_audio_timeout")) + return + end + runCommand({ "sleep", tostring(BLUETOOTH_CONNECT_RETRY_SECONDS) }, function() + waitForBluetoothOutput(command, address, attempt + 1) + end, 1000) + end) +end + +local function connectTarget(command, target) + local function afterPair() + runBluetoothctl({ "connect", target.address }, function(connectResult) + if connectResult.exitCode ~= 0 then + finishAction(command, false, trim(connectResult.stderr) ~= "" and trim(connectResult.stderr) or noctalia.tr("errors.bluetooth_connect_failed")) + return + end + assignSlot(target.address) + waitForBluetoothOutput(command, target.address, 0) + end) + end + if target.paired then + afterPair() + else + runBluetoothctl({ "pair", target.address }, function(pairResult) + if pairResult.exitCode ~= 0 then + finishAction(command, false, trim(pairResult.stderr) ~= "" and trim(pairResult.stderr) or noctalia.tr("errors.bluetooth_pair_failed")) + return + end + afterPair() + end) + end +end + +connectBluetooth = function(command, address) + if actionBusy then + resultMessage(command, false, noctalia.tr("errors.busy")) + return + end + address = tostring(address or ""):upper() + local target = findBluetooth(address) + if target == nil then + finishAction(command, false, noctalia.tr("errors.bluetooth_device_not_found")) + return + end + actionBusy = true + publishSnapshot() + + local connected = {} + for _, device in ipairs(snapshot.bluetooth) do + if device.connected and device.address ~= address then table.insert(connected, device.address) end + end + local index = 1 + local function disconnectNext() + local current = connected[index] + if current == nil then + connectTarget(command, target) + return + end + index += 1 + runBluetoothctl({ "disconnect", current }, function() + disconnectNext() + end) + end + disconnectNext() +end + +setOutput = function(command, id) + if actionBusy then + resultMessage(command, false, noctalia.tr("errors.busy")) + return + end + local device = findOutputById(id) + if device == nil then + finishAction(command, false, noctalia.tr("errors.device_not_found")) + return + end + if device.bluetooth and not device.available then + connectBluetooth(command, device.address) + return + end + setDefaultTarget(command, "output", device) +end + +setInput = function(command, id) + 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 + setDefaultTarget(command, "input", device) +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 + end + end + if #visible == 0 then + finishAction(command, false, noctalia.tr("errors.no_visible_devices")) + return + end + local nextIndex = activeIndex % #visible + 1 + if kind == "output" then setOutput(command, visible[nextIndex].id) else setInput(command, visible[nextIndex].id) end +end + +local function findAddressBySlot(slot) + slot = tonumber(slot) + if slot == nil then return nil end + for address, value in pairs(preferences.slots) do + if tonumber(value) == slot then return address end + end + return nil +end + +local function updateAlias(command) + local id = tostring(command.id or "") + if id == "" then + resultMessage(command, false, noctalia.tr("errors.device_not_found")) + return + end + local alias = trim(command.alias) + setPreference(preferences.aliases, id, alias ~= "" and alias or nil) + savePreferences() + resultMessage(command, true, noctalia.tr("notifications.preferences_saved")) + refreshAll() +end + +local function updateHidden(command) + local id = tostring(command.id or "") + local map = command.kind == "input" and preferences.hiddenInputs or preferences.hiddenOutputs + setPreference(map, id, command.hidden == true and true or nil) + savePreferences() + resultMessage(command, true, noctalia.tr("notifications.preferences_saved")) + refreshAll() +end + +local function updateSlot(command) + local address = tostring(command.address or bluetoothAddressFromId(command.id) or ""):upper() + local slot = tonumber(command.slot) + if address == "" or slot == nil or slot < 1 or slot > 99 or math.floor(slot) ~= slot then + resultMessage(command, false, noctalia.tr("errors.invalid_slot")) + return + end + for otherAddress, value in pairs(preferences.slots) do + if otherAddress ~= address and tonumber(value) == slot then + resultMessage(command, false, noctalia.tr("errors.slot_in_use", { slot = slot })) + return + end + end + setPreference(preferences.slots, address, slot) + savePreferences() + resultMessage(command, true, noctalia.tr("notifications.slot_saved", { slot = slot })) + refreshAll() +end + +local function updateDevice(command) + local id = tostring(command.id or "") + local address = tostring(command.address or bluetoothAddressFromId(id) or ""):upper() + if id == "" then + resultMessage(command, false, noctalia.tr("errors.device_not_found")) + return + end + + local slot = nil + if address ~= "" and trim(command.slot) ~= "" then + slot = tonumber(command.slot) + if slot == nil or slot < 1 or slot > 99 or math.floor(slot) ~= slot then + resultMessage(command, false, noctalia.tr("errors.invalid_slot")) + return + end + for otherAddress, value in pairs(preferences.slots) do + if otherAddress ~= address and tonumber(value) == slot then + resultMessage(command, false, noctalia.tr("errors.slot_in_use", { slot = slot })) + return + end + end + end + + local alias = trim(command.alias) + setPreference(preferences.aliases, id, alias ~= "" and alias or nil) + local iconStyle = validIconStyle(command.iconStyle) + setPreference(preferences.icons, id, iconStyle ~= "automatic" and iconStyle or nil) + if address ~= "" and slot ~= nil then setPreference(preferences.slots, address, slot) end + savePreferences() + resultMessage(command, true, noctalia.tr("notifications.preferences_saved")) + refreshAll() +end + +local function setVolume(command, kind) + local requested = tonumber(command.value) + if requested == nil then + resultMessage(command, false, noctalia.tr("errors.invalid_volume")) + return + end + local value = math.max(0, math.min(150, math.floor(requested))) + local operation = volumeOperations[kind] + local request = { command = command, value = value } + operation.desiredValue = value + + -- Publish wheel/slider requests immediately so an open panel follows the + -- requested volume while pactl serializes a burst of wheel events. A failed + -- command is reconciled by the authoritative refresh below. + if kind == "output" then + snapshot.outputVolume = value + else + snapshot.inputVolume = value + end + publishSnapshot() + + if operation.running then + -- Keep only the newest requested value. This bounds memory usage and avoids + -- out-of-order pactl completions while a wheel emits events quickly. + operation.pending = request + return + end + + local applyNext + applyNext = function(nextRequest) + operation.running = true + local target = kind == "output" and "@DEFAULT_SINK@" or "@DEFAULT_SOURCE@" + local pactlCommand = kind == "output" and "set-sink-volume" or "set-source-volume" + runPactl({ pactlCommand, target, tostring(nextRequest.value) .. "%" }, function(result) + resultMessage(nextRequest.command, result.exitCode == 0, trim(result.stderr)) + local pending = operation.pending + operation.pending = nil + if pending ~= nil then + applyNext(pending) + else + operation.running = false + if result.exitCode ~= 0 then operation.desiredValue = nil end + refreshAll() + end + end) + end + + applyNext(request) +end + +local function toggleMute(command, kind) + local target = kind == "output" and "@DEFAULT_SINK@" or "@DEFAULT_SOURCE@" + local pactlCommand = kind == "output" and "set-sink-mute" or "set-source-mute" + runPactl({ pactlCommand, target, "toggle" }, function(result) + resultMessage(command, result.exitCode == 0, trim(result.stderr)) + refreshAll() + end) +end + +local function disconnectBluetooth(command, address) + if actionBusy then + resultMessage(command, false, noctalia.tr("errors.busy")) + return + end + actionBusy = true + publishSnapshot() + runBluetoothctl({ "disconnect", tostring(address or ""):upper() }, function(result) + local ok = result.exitCode == 0 + finishAction(command, ok, ok and noctalia.tr("notifications.bluetooth_disconnected") or trim(result.stderr)) + end) +end + +local function scanBluetooth(command) + if scanning then + resultMessage(command, false, noctalia.tr("errors.scan_in_progress")) + return + end + scanning = true + publishSnapshot() + runBluetoothctl({ "--timeout", "6", "scan", "on" }, function(result) + scanning = false + resultMessage(command, result.exitCode == 0, result.exitCode == 0 and noctalia.tr("notifications.scan_complete") or trim(result.stderr)) + refreshAll() + end, 10000) +end + +local function executeCommand(command) + if type(command) ~= "table" or type(command.action) ~= "string" then return end + local action = command.action + if action == "refresh" then + refreshAll() + elseif action == "set_output" then + setOutput(command, tostring(command.id or "")) + elseif action == "set_input" then + setInput(command, tostring(command.id or "")) + elseif action == "cycle_output" then + cycleDevice(command, "output") + elseif action == "cycle_input" then + cycleDevice(command, "input") + elseif action == "connect_bluetooth" then + connectBluetooth(command, tostring(command.address or "")) + elseif action == "disconnect_bluetooth" then + disconnectBluetooth(command, tostring(command.address or "")) + elseif action == "scan_bluetooth" then + scanBluetooth(command) + elseif action == "set_alias" then + updateAlias(command) + elseif action == "set_hidden" then + updateHidden(command) + elseif action == "set_slot" then + updateSlot(command) + elseif action == "update_device" then + updateDevice(command) + elseif action == "set_output_volume" then + setVolume(command, "output") + elseif action == "set_input_volume" then + setVolume(command, "input") + elseif action == "toggle_output_mute" then + toggleMute(command, "output") + elseif action == "toggle_input_mute" then + toggleMute(command, "input") + end +end + +local function ipcCommand(action, payload) + return { + requestId = "ipc-" .. tostring(os.time()), + action = action, + payload = payload, + } +end + +function onIpc(event, payload) + if event == "cycle-output" then + executeCommand(ipcCommand("cycle_output", payload)) + elseif event == "cycle-input" then + executeCommand(ipcCommand("cycle_input", payload)) + elseif event == "connect" then + local command = ipcCommand("connect_bluetooth", payload) + command.address = findAddressBySlot(payload) + if command.address == nil then + resultMessage(command, false, noctalia.tr("errors.slot_not_found", { slot = tostring(payload or "") })) + notifyResult(false, noctalia.tr("errors.slot_not_found", { slot = tostring(payload or "") })) + return + end + executeCommand(command) + elseif event == "refresh" then + refreshAll() + end +end + +loadPreferences() +noctalia.state.watch(COMMAND_KEY, function(command) + executeCommand(command) +end) + +if noctalia.commandExists("pactl") then + local streamStarted = noctalia.runStream("LC_ALL=C pactl subscribe", function(line) + local isChange = tostring(line):find("Event 'change'", 1, true) ~= nil + if tostring(line):find(" on sink #", 1, true) ~= nil then + audioEventRevisions.output += 1 + if isChange then refreshCurrentVolume("output") else refreshAll() end + elseif tostring(line):find(" on source #", 1, true) ~= nil then + audioEventRevisions.input += 1 + if isChange then refreshCurrentVolume("input") else refreshAll() end + elseif tostring(line):find(" on server", 1, true) ~= nil then + refreshAll() + end + end) + if not streamStarted then noctalia.log("audio-switcher: could not subscribe to pactl events") end +end + +noctalia.setUpdateInterval(REFRESH_INTERVAL_MS) + +function update() + refreshAll() +end + +refreshAll() diff --git a/audio-switcher/thumbnail.webp b/audio-switcher/thumbnail.webp new file mode 100644 index 0000000..24f8752 Binary files /dev/null and b/audio-switcher/thumbnail.webp differ diff --git a/audio-switcher/translations/en.json b/audio-switcher/translations/en.json new file mode 100644 index 0000000..59892d1 --- /dev/null +++ b/audio-switcher/translations/en.json @@ -0,0 +1,101 @@ +{ + "title": "Audio Switcher", + "actions": { + "cancel": "Cancel", + "close": "Close", + "connect_and_use": "Connect & use", + "cycle": "Cycle", + "edit": "Edit device", + "hide": "Hide device", + "refresh": "Refresh", + "save": "Save", + "settings": "Plugin settings", + "show": "Show device", + "use": "Use" + }, + "device": { + "active": "Active", + "available": "Available", + "bluetooth_connected": "Bluetooth connected", + "bluetooth_disconnected": "Bluetooth disconnected", + "current": "Current" + }, + "editor": { + "alias": "Display name", + "icon": "Device icon", + "icon_options": { + "automatic": "Automatic", + "speaker": "Speaker", + "over_ear": "Over-ear headphones", + "tws": "TWS earbuds", + "wired": "Wired headphones" + }, + "slot": "Keybind number", + "slot_hint": "Use this number with the connect IPC command, for example: connect 2.", + "title": "Device preferences" + }, + "errors": { + "audio_unavailable": "The audio service is unavailable.", + "bluetooth_audio_timeout": "Bluetooth connected, but its audio output did not appear in PipeWire in time.", + "bluetooth_connect_failed": "Could not connect the Bluetooth device.", + "bluetooth_device_not_found": "The Bluetooth device was not found. Try scanning again.", + "bluetooth_pair_failed": "Could not pair the Bluetooth device.", + "busy": "Another audio operation is still running.", + "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.", + "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.", + "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.", + "slot_in_use": "Keybind number {slot} is already assigned to another device.", + "slot_not_found": "No Bluetooth device is assigned to keybind number {slot}.", + "switch_failed": "Could not switch the default audio device." + }, + "notifications": { + "bluetooth_disconnected": "Bluetooth device disconnected.", + "input_selected": "Input switched to {device}.", + "output_selected": "Output switched to {device}.", + "preferences_saved": "Device preferences saved.", + "scan_complete": "Bluetooth scan complete.", + "slot_saved": "Keybind number {slot} saved." + }, + "settings": { + "show_percentage": { + "label": "Show percentage", + "description": "Show the current output volume percentage next to the bar icon. Disable it to show only the icon." + }, + "scroll_step": { + "label": "Scroll step", + "description": "How many percentage points each mouse-wheel step changes volume on the bar widget." + } + }, + "panel": { + "hidden": "Hidden", + "hidden_count": "Hidden {count}", + "input_volume": "Microphone", + "keybind_hint": "Hidden devices are skipped by cycle-output and cycle-input. Bluetooth numbers can be used with the connect IPC command.", + "loading": "Loading audio devices…", + "mute": "Mute", + "no_device": "No device", + "no_devices": "No audio devices found.", + "no_input": "No input", + "no_output": "No output", + "no_visible_devices": "All devices in this list are hidden.", + "output_volume": "Sound", + "scan_bluetooth": "Scan for Bluetooth audio devices", + "scanning": "Scanning for Bluetooth devices…", + "switching": "Switching audio device…", + "unmute": "Unmute", + "visible_count": "Visible {count}" + }, + "tabs": { + "inputs": "Inputs", + "outputs": "Outputs" + }, + "widget": { + "tooltip": "Output: {output} ({output_volume})\nInput: {input} ({input_volume})\nScroll: change output volume\nLeft click: open Audio Switcher\nRight click: cycle outputs\nMiddle click: cycle inputs" + } +} diff --git a/audio-switcher/widget.luau b/audio-switcher/widget.luau new file mode 100644 index 0000000..d35edff --- /dev/null +++ b/audio-switcher/widget.luau @@ -0,0 +1,126 @@ +--!nonstrict + +local PANEL_ID = "blackbartblues/audio-switcher:audio-switcher" +local SNAPSHOT_KEY = "audio_switcher_snapshot" +local COMMAND_KEY = "audio_switcher_command" +local RESULT_KEY = "audio_switcher_result" + +local snapshot = noctalia.state.get(SNAPSHOT_KEY) or { + available = false, + loading = true, + outputs = {}, + inputs = {}, + outputVolume = 0, + inputVolume = 0, + outputMuted = false, +} +local requestId = 0 +local outputVolumeDraft = nil + +local function activeDevice(devices, fallback) + for _, device in ipairs(type(devices) == "table" and devices or {}) do + if device.active == true then return device end + end + return nil +end + +local function percent(value) + return tostring(math.floor((tonumber(value) or 0) + 0.5)) .. "%" +end + +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 outputVolume = percent(outputVolumeDraft or snapshot.outputVolume) + local inputVolume = percent(snapshot.inputVolume) + + local glyph = snapshot.outputMuted == true and "volume-off" or "volume" + if outputDevice ~= nil and snapshot.outputMuted ~= true then + glyph = tostring(outputDevice.icon or "volume") + end + + barWidget.setGlyph(glyph) + if snapshot.busy == true then + barWidget.setGlyphColor("secondary") + elseif snapshot.available == true then + barWidget.setGlyphColor("primary") + else + barWidget.setGlyphColor("error") + end + + local showPercentage = noctalia.getConfig("show_percentage") ~= false + barWidget.setText(showPercentage and not barWidget.isVertical() and outputVolume or "") + barWidget.setTooltip(noctalia.tr("widget.tooltip", { + output = outputName, + output_volume = outputVolume, + input = inputName, + input_volume = inputVolume, + })) +end + +local function dispatch(action) + requestId += 1 + noctalia.state.set(COMMAND_KEY, { + action = action, + requestId = "widget-" .. tostring(os.time()) .. "-" .. tostring(requestId), + }) +end + +noctalia.state.watch(SNAPSHOT_KEY, function(value) + if type(value) == "table" then + if outputVolumeDraft ~= nil and tonumber(value.outputVolume) == outputVolumeDraft then + outputVolumeDraft = nil + end + snapshot = value + render() + end +end) + +noctalia.state.watch(RESULT_KEY, function(result) + if type(result) ~= "table" or not tostring(result.requestId or ""):match("^widget%-scroll%-") then return end + if result.ok ~= true then + outputVolumeDraft = nil + render() + end +end) + +function update() + render() +end + +function onConfigChanged() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + dispatch("cycle_output") +end + +function onMiddleClick() + dispatch("cycle_input") +end + +function onScroll(axis, steps) + if axis ~= "vertical" then return end + local detents = tonumber(steps) or 0 + if detents == 0 then return end + local step = math.max(1, math.min(25, math.floor(tonumber(noctalia.getConfig("scroll_step")) or 5))) + local current = tonumber(outputVolumeDraft or snapshot.outputVolume) or 0 + outputVolumeDraft = math.max(0, math.min(100, math.floor(current - detents * step + 0.5))) + render() + requestId += 1 + noctalia.state.set(COMMAND_KEY, { + action = "set_output_volume", + value = outputVolumeDraft, + requestId = "widget-scroll-" .. tostring(os.time()) .. "-" .. tostring(requestId), + }) +end + +noctalia.setUpdateInterval(60000) +render()