Add Audio Switcher plugin (#82)

* feat: add Audio Switcher plugin

* fix: hide null audio descriptions

* docs: add focused panel screenshot

* docs: focus thumbnail on plugin panel
This commit is contained in:
blacku
2026-07-23 18:57:39 -04:00
committed by GitHub
parent 2d9c72ae29
commit d4467e907c
8 changed files with 2034 additions and 0 deletions
+102
View File
@@ -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.
+501
View File
@@ -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()
+44
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+101
View File
@@ -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"
}
}
+126
View File
@@ -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()