Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions audio-output-switcher/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Audio Output Switcher

Route individual apps to different audio outputs. Each playing app appears in the panel with buttons to switch it to any available output (headphones, HDMI, Bluetooth, USB, etc.). Works with PipeWire/PulseAudio.

## Plugin

| Field | Value |
| --- | --- |
| ID | `rael2pac/audio-output-switcher` |
| Entries | Bar widget: `bar`; panel: `panel`; service: `audio-poller` |

## Usage

The bar widget shows a speaker icon. Click it to open the panel listing all currently playing audio streams with buttons for each available output.

Each stream shows the app name with its icon (Firefox, Spotify, etc.) and the current output. Click any output button to route that app to a different output.

```sh
noctalia msg panel-toggle rael2pac/audio-output-switcher:panel
```

## Settings

| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `refreshInterval` | `int` | `3` | Seconds between audio stream polls (min 1, max 15). |

## Notes

- Requires PipeWire or PulseAudio running on the system.
- Uses `pactl` to list sinks and move sink inputs between outputs.
- Auto-detects output names from the internal device names (brand, type).
- Supports up to 5 audio outputs and 8 simultaneous streams.
223 changes: 223 additions & 0 deletions audio-output-switcher/panel.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
-- panel.luau -- Audio Switcher: per-app audio routing

local audioData = nil
local panelOpen = false

local function tr(key, fallback)
local v = noctalia.tr(key)
if v == nil or v == key then return fallback or key end
return v
end

local function getFriendlyName(sinkName)
if not sinkName then return "?" end
if sinkName:match("bluez") then return "Bluetooth" end
if sinkName:match("hdmi") then return "HDMI" end
if sinkName:match("iec958") then return "Digital" end
local brand = sinkName:match("usb%-([^_]+)")
if brand then
local upper = brand:upper()
local model = sinkName:match("usb%-" .. brand .. "_" .. upper .. "_([^_]+)")
if model then return brand .. " " .. model end
return brand
end
if sinkName:match("analog%-stereo") then return "Analogico" end
return sinkName:sub(1, 30)
end

local function getSinkIcon(name)
if not name then return "speakerphone" end
if name:match("bluez") then return "bluetooth" end
if name:match("hdmi") then return "device-desktop" end
if name:match("iec958") then return "volume" end
if name:match("usb") then return "headphones" end
return "speakerphone"
end

local function getStreamIcon(appName)
if not appName then return "app-window" end
if appName:match("Firefox") then return "brand-firefox" end
if appName:match("Chromium") or appName:match("Chrome") then return "brand-chrome" end
if appName:match("Telegram") then return "brand-telegram" end
if appName:match("Spotify") then return "brand-spotify" end
if appName:match("mpv") then return "player-play" end
if appName:match("Discord") then return "brand-discord" end
return "app-window"
end

local function doMoveSinkInput(streamId, sinkId)
noctalia.runAsync("pactl move-sink-input " .. tostring(streamId) .. " " .. tostring(sinkId), function()
noctalia.runAsync("pactl list sink-inputs 2>/dev/null", function(result)
if result.exitCode == 0 and result.stdout then
local newStreams = {}
local curId, curSink, curName = nil, nil, nil
for line in result.stdout:gmatch("[^\n]*") do
local id = line:match("^Sink Input #(%d+)")
if id then
if curId and curName then
table.insert(newStreams, { id = curId, name = curName, sink = curSink or 0 })
end
curId = tonumber(id)
curName = nil
curSink = nil
end
local snk = line:match("^%s+Sink: (%d+)")
if snk and curId then curSink = tonumber(snk) end
local app = line:match('application.name = "(.+)"')
if app and curId then curName = app end
end
if curId and curName then
table.insert(newStreams, { id = curId, name = curName, sink = curSink or 0 })
end
if audioData then audioData.streams = newStreams end
end
render()
end)
end)
end

function onR_1_1() if audioData and audioData.streams[1] and audioData.sinks[1] then doMoveSinkInput(audioData.streams[1].id, audioData.sinks[1].id) end end
function onR_1_2() if audioData and audioData.streams[1] and audioData.sinks[2] then doMoveSinkInput(audioData.streams[1].id, audioData.sinks[2].id) end end
function onR_1_3() if audioData and audioData.streams[1] and audioData.sinks[3] then doMoveSinkInput(audioData.streams[1].id, audioData.sinks[3].id) end end
function onR_1_4() if audioData and audioData.streams[1] and audioData.sinks[4] then doMoveSinkInput(audioData.streams[1].id, audioData.sinks[4].id) end end
function onR_1_5() if audioData and audioData.streams[1] and audioData.sinks[5] then doMoveSinkInput(audioData.streams[1].id, audioData.sinks[5].id) end end

function onR_2_1() if audioData and audioData.streams[2] and audioData.sinks[1] then doMoveSinkInput(audioData.streams[2].id, audioData.sinks[1].id) end end
function onR_2_2() if audioData and audioData.streams[2] and audioData.sinks[2] then doMoveSinkInput(audioData.streams[2].id, audioData.sinks[2].id) end end
function onR_2_3() if audioData and audioData.streams[2] and audioData.sinks[3] then doMoveSinkInput(audioData.streams[2].id, audioData.sinks[3].id) end end
function onR_2_4() if audioData and audioData.streams[2] and audioData.sinks[4] then doMoveSinkInput(audioData.streams[2].id, audioData.sinks[4].id) end end
function onR_2_5() if audioData and audioData.streams[2] and audioData.sinks[5] then doMoveSinkInput(audioData.streams[2].id, audioData.sinks[5].id) end end

function onR_3_1() if audioData and audioData.streams[3] and audioData.sinks[1] then doMoveSinkInput(audioData.streams[3].id, audioData.sinks[1].id) end end
function onR_3_2() if audioData and audioData.streams[3] and audioData.sinks[2] then doMoveSinkInput(audioData.streams[3].id, audioData.sinks[2].id) end end
function onR_3_3() if audioData and audioData.streams[3] and audioData.sinks[3] then doMoveSinkInput(audioData.streams[3].id, audioData.sinks[3].id) end end
function onR_3_4() if audioData and audioData.streams[3] and audioData.sinks[4] then doMoveSinkInput(audioData.streams[3].id, audioData.sinks[4].id) end end
function onR_3_5() if audioData and audioData.streams[3] and audioData.sinks[5] then doMoveSinkInput(audioData.streams[3].id, audioData.sinks[5].id) end end

function onR_4_1() if audioData and audioData.streams[4] and audioData.sinks[1] then doMoveSinkInput(audioData.streams[4].id, audioData.sinks[1].id) end end
function onR_4_2() if audioData and audioData.streams[4] and audioData.sinks[2] then doMoveSinkInput(audioData.streams[4].id, audioData.sinks[2].id) end end
function onR_4_3() if audioData and audioData.streams[4] and audioData.sinks[3] then doMoveSinkInput(audioData.streams[4].id, audioData.sinks[3].id) end end
function onR_4_4() if audioData and audioData.streams[4] and audioData.sinks[4] then doMoveSinkInput(audioData.streams[4].id, audioData.sinks[4].id) end end
function onR_4_5() if audioData and audioData.streams[4] and audioData.sinks[5] then doMoveSinkInput(audioData.streams[4].id, audioData.sinks[5].id) end end

function onR_5_1() if audioData and audioData.streams[5] and audioData.sinks[1] then doMoveSinkInput(audioData.streams[5].id, audioData.sinks[1].id) end end
function onR_5_2() if audioData and audioData.streams[5] and audioData.sinks[2] then doMoveSinkInput(audioData.streams[5].id, audioData.sinks[2].id) end end
function onR_5_3() if audioData and audioData.streams[5] and audioData.sinks[3] then doMoveSinkInput(audioData.streams[5].id, audioData.sinks[3].id) end end
function onR_5_4() if audioData and audioData.streams[5] and audioData.sinks[4] then doMoveSinkInput(audioData.streams[5].id, audioData.sinks[4].id) end end
function onR_5_5() if audioData and audioData.streams[5] and audioData.sinks[5] then doMoveSinkInput(audioData.streams[5].id, audioData.sinks[5].id) end end

function onR_6_1() if audioData and audioData.streams[6] and audioData.sinks[1] then doMoveSinkInput(audioData.streams[6].id, audioData.sinks[1].id) end end
function onR_6_2() if audioData and audioData.streams[6] and audioData.sinks[2] then doMoveSinkInput(audioData.streams[6].id, audioData.sinks[2].id) end end
function onR_6_3() if audioData and audioData.streams[6] and audioData.sinks[3] then doMoveSinkInput(audioData.streams[6].id, audioData.sinks[3].id) end end
function onR_6_4() if audioData and audioData.streams[6] and audioData.sinks[4] then doMoveSinkInput(audioData.streams[6].id, audioData.sinks[4].id) end end
function onR_6_5() if audioData and audioData.streams[6] and audioData.sinks[5] then doMoveSinkInput(audioData.streams[6].id, audioData.sinks[5].id) end end

function render()
local body = {}

if not audioData or not audioData.streams or #audioData.streams == 0 then
table.insert(body, ui.column({ align = "center", justify = "center", flexGrow = 1, padding = 30 }, {
ui.glyph({ name = "speaker-off", size = 28, color = "secondary" }),
ui.label({ text = tr("panel.noStreams", "Nenhum audio tocando"), fontSize = 14, color = "on_surface/0.6" }),
}))
else
for i, stream in ipairs(audioData.streams) do
local sinkLabel = "?"
local sinkIcon = "speakerphone"
if audioData.sinks then
for _, s in ipairs(audioData.sinks) do
if s.id == stream.sink then
sinkLabel = getFriendlyName(s.name)
sinkIcon = getSinkIcon(s.name)
break
end
end
end
local appIcon = getStreamIcon(stream.name)

table.insert(body, ui.column({ gap = 4, paddingH = 8, paddingV = 8, fill = "surface_variant", radius = 8 }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = appIcon, size = 18, color = "primary" }),
ui.label({ text = stream.name, fontSize = 14, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
}),
ui.row({ gap = 4, align = "center" }, {
ui.glyph({ name = sinkIcon, size = 12, color = "on_surface/0.5" }),
ui.label({ text = sinkLabel, fontSize = 11, color = "on_surface/0.5", flexGrow = 1 }),
}),
}))

if audioData.sinks then
local sinkButtons = {}
for j, sink in ipairs(audioData.sinks) do
if j <= 5 then
local isSelected = (stream.sink == sink.id)
local variant = isSelected and "default" or "outline"
local icon = getSinkIcon(sink.name)
local label = getFriendlyName(sink.name)
local cb = "onR_" .. i .. "_" .. j
table.insert(sinkButtons, ui.button({
text = label,
glyph = icon,
variant = variant,
controlSize = "sm",
onClick = cb,
}))
end
end
table.insert(body, ui.column({ gap = 4, paddingH = 8 }, {
ui.row({ gap = 4, wrap = true }, sinkButtons),
}))
end

table.insert(body, ui.separator({ padding = 0 }))
end
end

panel.render(ui.column({ flexGrow = 1, gap = 6, padding = 12, fill = "surface", radius = 12 }, {
ui.row({ gap = 10, align = "center" }, {
ui.glyph({ name = "speakerphone", size = 18, color = "primary" }),
ui.label({ text = tr("panel.title", "Audio Switcher"), fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.button({ glyph = "x", variant = "ghost", controlSize = "sm", onClick = "onCloseClicked" }),
}),
ui.scroll({ flexGrow = 1, gap = 8 }, body),
}))
end

function onOpen(_context)
panelOpen = true
local raw = noctalia.state.get("audio")
if raw and raw ~= "" then
local ok, d = pcall(noctalia.json.decode, raw)
if ok then
audioData = d
else
audioData = nil
end
else
audioData = nil
end
render()
end

function onClose()
panelOpen = false
audioData = nil
end

function onCloseClicked()
panel.close()
end

noctalia.state.watch("audio", function(value)
if value and value ~= "" then
local ok, d = pcall(noctalia.json.decode, value)
if ok then
audioData = d
else
audioData = nil
end
else
audioData = nil
end
if panelOpen then render() end
end)
34 changes: 34 additions & 0 deletions audio-output-switcher/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
id = "rael2pac/audio-output-switcher"
name = "Audio Output Switcher"
version = "1.2.6"
plugin_api = 3
author = "rael2pac"
license = "MIT"
icon = "speakerphone"
description = "Route individual apps to different audio outputs from the bar or panel"
tags = ["bar", "panel", "audio", "system", "utility"]

[[service]]
id = "audio-poller"
entry = "service.luau"

[[widget]]
id = "bar"
entry = "widget.luau"

[[panel]]
id = "panel"
entry = "panel.luau"
width = 500
height = 400
placement = "attached"
open_near_click = false

[[setting]]
key = "refreshInterval"
type = "int"
label_key = "settings.refreshInterval.label"
description_key = "settings.refreshInterval.description"
default = 3
min = 1
max = 15
80 changes: 80 additions & 0 deletions audio-output-switcher/service.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
-- service.luau -- Audio Switcher: per-app audio routing

local function parseSinkInputs(text)
local list = {}
if not text or text == "" then return list end
local curId, curSink, curName = nil, nil, nil
for line in text:gmatch("[^\n]*") do
local id = line:match("^Sink Input #(%d+)")
if id then
if curId and curName then
table.insert(list, { id = curId, name = curName, sink = curSink or 0 })
end
curId = tonumber(id)
curName = nil
curSink = nil
end
local snk = line:match("^%s+Sink: (%d+)")
if snk and curId then curSink = tonumber(snk) end
local app = line:match('application.name = "(.+)"')
if app and curId then curName = app end
end
if curId and curName then
table.insert(list, { id = curId, name = curName, sink = curSink or 0 })
end
return list
end

local function parseSinksShort(text)
local list = {}
if not text or text == "" then return list end
for line in text:gmatch("[^\n]*") do
local id, name = line:match("^(%d+)\t([^\t]+)")
if id and name then
table.insert(list, {
id = tonumber(id),
name = name,
})
end
end
return list
end

local function refresh()
noctalia.runAsync("pactl list sink-inputs 2>/dev/null", function(inputResult)
local streams = {}
if inputResult.exitCode == 0 and inputResult.stdout and inputResult.stdout ~= "" then
streams = parseSinkInputs(inputResult.stdout)
end

noctalia.runAsync("pactl list sinks short 2>/dev/null", function(sinkResult)
local sinks = {}
if sinkResult.exitCode == 0 and sinkResult.stdout and sinkResult.stdout ~= "" then
sinks = parseSinksShort(sinkResult.stdout)
end

noctalia.runAsync("pactl get-default-sink 2>/dev/null", function(defaultResult)
local defaultSink = ""
if defaultResult.exitCode == 0 and defaultResult.stdout then
defaultSink = defaultResult.stdout:gsub("%s+", "")
end

local state = {
streams = streams,
sinks = sinks,
default = defaultSink,
}
noctalia.state.set("audio", noctalia.json.encode(state))
end)
end)
end)
end

local interval = noctalia.getConfig("refreshInterval") or 3
noctalia.setUpdateInterval(interval * 1000)

function update()
refresh()
end

refresh()
Binary file added audio-output-switcher/thumbnail.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions audio-output-switcher/translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"settings.refreshInterval.label": "Refresh Interval",
"settings.refreshInterval.description": "Seconds between audio stream polls",
"panel.title": "Audio Output Switcher",
"panel.noStreams": "No audio playing",
"widget.tooltip": "Audio Output Switcher"
}
Loading
Loading