diff --git a/audio-output-switcher/README.md b/audio-output-switcher/README.md new file mode 100644 index 00000000..9bbce582 --- /dev/null +++ b/audio-output-switcher/README.md @@ -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. diff --git a/audio-output-switcher/panel.luau b/audio-output-switcher/panel.luau new file mode 100644 index 00000000..c770d839 --- /dev/null +++ b/audio-output-switcher/panel.luau @@ -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) diff --git a/audio-output-switcher/plugin.toml b/audio-output-switcher/plugin.toml new file mode 100644 index 00000000..8031cb34 --- /dev/null +++ b/audio-output-switcher/plugin.toml @@ -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 diff --git a/audio-output-switcher/service.luau b/audio-output-switcher/service.luau new file mode 100644 index 00000000..409a322e --- /dev/null +++ b/audio-output-switcher/service.luau @@ -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() diff --git a/audio-output-switcher/thumbnail.webp b/audio-output-switcher/thumbnail.webp new file mode 100644 index 00000000..a3ad4ffa Binary files /dev/null and b/audio-output-switcher/thumbnail.webp differ diff --git a/audio-output-switcher/translations/en.json b/audio-output-switcher/translations/en.json new file mode 100644 index 00000000..f927b950 --- /dev/null +++ b/audio-output-switcher/translations/en.json @@ -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" +} diff --git a/audio-output-switcher/widget.luau b/audio-output-switcher/widget.luau new file mode 100644 index 00000000..412df813 --- /dev/null +++ b/audio-output-switcher/widget.luau @@ -0,0 +1,49 @@ +-- widget.luau -- Audio Switcher: bar widget + +local streamCount = 0 +local loaded = 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 render() + if not loaded then + barWidget.setVisible(true) + barWidget.render(ui.row({ gap = 5, align = "center" }, { + ui.glyph({ name = "speakerphone", size = 16, color = "on_surface" }), + })) + return + end + + barWidget.setVisible(true) + + barWidget.render(ui.row({ gap = 5, align = "center" }, { + ui.glyph({ name = "speakerphone", size = 16, color = "on_surface" }), + })) + + barWidget.setTooltip(tr("widget.tooltip", "Roteamento de audio")) +end + +noctalia.state.watch("audio", function(value) + if value and value ~= "" then + local ok, d = pcall(noctalia.json.decode, value) + if ok and d and d.streams then + streamCount = #d.streams + else + streamCount = 0 + end + else + streamCount = 0 + end + loaded = true + render() +end) + +function onClick() + noctalia.togglePanel("rael2pac/audio-switcher:panel") +end + +render()