Migration of my Radio panel to luau
I used to use noctalia v4 where you could add eg widgets to panels.
I created a widget to expand the default bar.
Basically it allows me to manage json file with links to (youtube)-Radiostations.
Hovering over a station previews whats currently playing while a click lets it run in the background.
Basically all this does is executing mpv --no-video --no-terminal --really-quiet $radio_uri
Behold the original code:
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Item {
property var pluginApi: null
property var stations: stationsAdapter.stations || []
property string playingName: ""
property string previewingName: ""
readonly property string stationsFile: Settings.configDir + "plugins/beats/stations.json"
IpcHandler {
target: "plugin:beats"
function toggle() {
if (!pluginApi)
return;
if (pluginApi.panelOpenScreen) {
pluginApi.closePanel(pluginApi.panelOpenScreen);
} else {
pluginApi.withCurrentScreen(screen => {
pluginApi.openPanel(screen);
});
}
}
}
FileView {
id: stationsFileView
path: stationsFile
printErrors: false
JsonAdapter {
id: stationsAdapter
property var stations: []
}
onLoadFailed: error => {
stationsAdapter.stations = defaultStations();
stationsFileView.writeAdapter();
}
}
function defaultStations() {
return [
{ name: "Lofi Girl", url: "https://play.streamafrica.net/lofiradio", category: "Radio" },
{ name: "Chillhop", url: "http://stream.zeno.fm/fyn8eh3h5f8uv", category: "Radio" },
{ name: "Ibiza Global", url: "https://filtermusic.net/ibiza-global", category: "Radio" },
{ name: "Metal Music", url: "https://tunein.com/radio/mETaLmuSicRaDio-s119867/", category: "Radio" },
{ name: "Easy Rock 96.3", url: "https://radio-stations-philippines.com/easy-rock", category: "FM" },
{ name: "Easy Rock Baguio 91.9", url: "https://radio-stations-philippines.com/easy-rock-baguio", category: "FM" },
{ name: "Love Radio 90.7", url: "https://radio-stations-philippines.com/love", category: "FM" },
{ name: "WRock CEBU 96.3", url: "https://onlineradio.ph/126-96-3-wrock.html", category: "FM" },
{ name: "Fresh Philippines", url: "https://onlineradio.ph/553-fresh-fm.html", category: "FM" },
{ name: "Wish 107.5 Pinoy HipHop", url: "https://youtube.com/playlist?list=PLkrzfEDjeYJnmgMYwCKid4XIFqUKBVWEs&si=vahW_noh4UDJ5d37", category: "YouTube" },
{ name: "Top 100 Songs Global", url: "https://youtube.com/playlist?list=PL4fGSI1pDJn6puJdseH2Rt9sMvt9E2M4i&si=5jsyfqcoUXBCSLeu", category: "YouTube" },
{ name: "Wish 107.5 Wishclusives", url: "https://youtube.com/playlist?list=PLkrzfEDjeYJn5B22H9HOWP3Kxxs-DkPSM&si=d_Ld2OKhGvpH48WO", category: "YouTube" },
{ name: "Relaxing Piano Music", url: "https://youtu.be/6H7hXzjFoVU?si=nZTPREC9lnK1JJUG", category: "YouTube" },
{ name: "Youtube Remix", url: "https://youtube.com/playlist?list=PLeqTkIUlrZXlSNn3tcXAa-zbo95j0iN-0", category: "YouTube" },
{ name: "Korean Drama OST", url: "https://youtube.com/playlist?list=PLUge_o9AIFp4HuA-A3e3ZqENh63LuRRlQ", category: "YouTube" },
{ name: "lofi hip hop radio beats", url: "https://www.youtube.com/live/jfKfPfyJRdk?si=PnJIA9ErQIAw6-qd", category: "YouTube" },
{ name: "Relaxing Piano Jazz", url: "https://youtu.be/85UEqRat6E4?si=jXQL1Yp2VP_G6NSn", category: "YouTube" }
];
}
Process {
id: previewProcess
running: false
onExited: {
previewingName = "";
}
}
function startPreview(url, name) {
stopPreview();
previewingName = name;
previewProcess.command = ["mpv", "--no-video", "--no-terminal", "--really-quiet", url];
previewProcess.running = true;
}
function stopPreview() {
if (previewProcess.running) {
previewProcess.signal(15); // SIGTERM
}
previewingName = "";
}
property string _pendingPlayUrl: ""
property string _pendingPlayName: ""
function playStation(url, name) {
stopPreview();
_pendingPlayUrl = url;
_pendingPlayName = name;
_killMpv();
}
Process {
id: stopProcess
running: false
onExited: {
if (_pendingPlayUrl) {
var url = _pendingPlayUrl;
var name = _pendingPlayName;
_pendingPlayUrl = "";
_pendingPlayName = "";
playingName = name;
Quickshell.execDetached(["mpv", "--no-video", "--no-terminal", url]);
if (pluginApi)
pluginApi.closePanel(pluginApi.panelOpenScreen);
}
}
}
function _killMpv() {
stopProcess.command = ["bash", "-c",
"mpv_pids=$(pgrep -x mpv); " +
"mpvpaper_pid=$(ps aux | grep -- 'unique-wallpaper-process' | grep -v grep | awk '{print $2}'); " +
"for pid in $mpv_pids; do " +
" if ! echo \"$mpvpaper_pid\" | grep -q \"$pid\"; then " +
" kill -9 $pid 2>/dev/null || true; " +
" fi; " +
"done"
];
stopProcess.running = true;
}
function stopPlayback() {
playingName = "";
_pendingPlayUrl = "";
_pendingPlayName = "";
_killMpv();
}
function addStation(name, url, category) {
var list = stations.slice();
list.push({ name: name, url: url, category: category });
stationsAdapter.stations = list;
stationsFileView.writeAdapter();
}
function removeStation(index) {
var list = stations.slice();
list.splice(index, 1);
stationsAdapter.stations = list;
stationsFileView.writeAdapter();
}
}
Pretty simple, right ?
Yet noctalia is currently migrating away from quickshell and all plugins have to be rewritten to luau.
I really like lua so I really appreciate the transition.
As we can use the “glorious” claude fable until July 19th I thought I could give it a try.
So code should be simple enough, shouldn’t it ?
The Prompt
My prompt was pretty simple.,
I am migrating from Noctalia v4 to Noctalia v5.
You can find all informations on how to write a plugin here: https://docs.noctalia.dev/v5/plugins
Please migrate my radiostation widget from qml to luau so that I can use it again.
The basic functionality is the retrieval of preconfigured urls - mostly youtube - extended by a json file.
The widget's panel allows me to navigate through each station
where a hoverstop plays the station in the background until I move away.
A click on a play button starts the station for background playback.
A + button inside the widget lets me dynamically add stations to the json.
A 🗑️ button removes a station.
Drill me about design decisions in case something is not clear.
Try your best to keep all the functionality intact.
And as always: Make no mistakes
The Result
The result was… shocking to say the least.

It took a shockingly 9% of my weekly fable usage to produce… this.
Working
- Playback
- Preview
- Adding station
Broken
- old stations.json discarded
- Magic 30 station limit introduced
- Only 9/17 default stations added
- Pretty ugly frontend
- No hover preview (replaced by an extra button)
Code
And this is the resulting code.
For some reason it is almost hillariously bigger than my initial code.
Besides what by the power of grayskull is that…
function onPreview0() previewAt(0) end
function onPreview1() previewAt(1) end
function onPreview2() previewAt(2) end
function onPreview3() previewAt(3) end
function onPreview4() previewAt(4) end
function onPreview5() previewAt(5) endIsn’t that just a meme?
--!nonstrict
local DATA = noctalia.pluginDataDir() or "/tmp/noctalia-beats"
local STATIONS_FILE = DATA .. "/stations.json"
local PLAY_PID = DATA .. "/play.pid"
local PREVIEW_PID = DATA .. "/preview.pid"
local MAX_ROWS = 30
local stations = {}
local search = ""
local playingName = ""
local previewingName = ""
local showAddForm = false
local newName = ""
local newUrl = ""
local newCategory = "Radio"
local addNonce = 0
local pickSlots = {}
local render
local function defaultStations()
return {
{ name = "Lofi Girl", url = "https://play.streamafrica.net/lofiradio", category = "Radio" },
{ name = "Chillhop", url = "http://stream.zeno.fm/fyn8eh3h5f8uv", category = "Radio" },
{ name = "Ibiza Global", url = "https://filtermusic.net/ibiza-global", category = "Radio" },
{ name = "Metal Music", url = "https://tunein.com/radio/mETaLmuSicRaDio-s119867/", category = "Radio" },
{ name = "Easy Rock 96.3", url = "https://radio-stations-philippines.com/easy-rock", category = "FM" },
{ name = "Love Radio 90.7", url = "https://radio-stations-philippines.com/love", category = "FM" },
{ name = "Fresh Philippines", url = "https://onlineradio.ph/553-fresh-fm.html", category = "FM" },
{ name = "lofi hip hop radio beats", url = "https://www.youtube.com/live/jfKfPfyJRdk", category = "YouTube" },
{ name = "Relaxing Piano Music", url = "https://youtu.be/6H7hXzjFoVU", category = "YouTube" },
{ name = "Relaxing Piano Jazz", url = "https://youtu.be/85UEqRat6E4", category = "YouTube" },
}
end
local function shellQuote(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
local function saveStations()
noctalia.mkdirAll(DATA)
noctalia.writeFile(STATIONS_FILE, noctalia.json.encode(stations))
end
local function loadStations()
local content = noctalia.readFile(STATIONS_FILE)
if content and content ~= "" then
local ok, data = pcall(noctalia.json.decode, content)
if ok and type(data) == "table" and #data > 0 then
stations = data
return
end
end
stations = defaultStations()
saveStations()
end
local function killSnippet(pidfile)
local qf = shellQuote(pidfile)
return "p=$(cat " .. qf .. " 2>/dev/null); "
.. "if [ -n \"$p\" ] && [ \"$(cat /proc/$p/comm 2>/dev/null)\" = mpv ]; then kill \"$p\" 2>/dev/null; fi; "
.. "rm -f " .. qf
end
local function startStream(url, pidfile)
local cmd = killSnippet(PLAY_PID) .. "; " .. killSnippet(PREVIEW_PID) .. "; "
.. "mpv --no-video --no-terminal --really-quiet " .. shellQuote(url) .. " >/dev/null 2>&1 & "
.. "echo $! > " .. shellQuote(pidfile)
noctalia.runAsync(cmd)
end
local function preview(station)
startStream(station.url, PREVIEW_PID)
previewingName = station.name
playingName = ""
end
local function stopPreview()
noctalia.runAsync(killSnippet(PREVIEW_PID))
previewingName = ""
end
local function play(station)
startStream(station.url, PLAY_PID)
playingName = station.name
previewingName = ""
panel.close()
end
local function stopAll()
noctalia.runAsync(killSnippet(PLAY_PID) .. "; " .. killSnippet(PREVIEW_PID))
playingName = ""
previewingName = ""
end
local function filtered()
local q = search:lower()
if q == "" then
return stations
end
local out = {}
for _, s in ipairs(stations) do
if s.name:lower():find(q, 1, true) or (s.category or ""):lower():find(q, 1, true) then
table.insert(out, s)
end
end
return out
end
local function addStation(name, url, category)
table.insert(stations, { name = name, url = url, category = category })
saveStations()
end
local function removeStation(station)
for i, s in ipairs(stations) do
if s == station then
table.remove(stations, i)
break
end
end
if previewingName == station.name then
stopPreview()
end
saveStations()
end
local function header()
return ui.row({ gap = 8, align = "center" }, {
ui.label({ text = "Beats", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.input({
key = "search",
placeholder = "Filter...",
value = search,
width = 200,
controlSize = "sm",
focus = true,
onChange = "onSearchChange",
onSubmit = "onSearchSubmit",
}),
ui.button({ glyph = "player-stop", variant = "ghost", controlSize = "sm", tooltip = "Stop", onClick = "onStopAll" }),
ui.button({
glyph = if showAddForm then "x" else "plus",
variant = "ghost",
controlSize = "sm",
tooltip = "Add station",
onClick = "onToggleAdd",
}),
ui.button({ glyph = "x", variant = "ghost", controlSize = "sm", onClick = "onCloseClicked" }),
})
end
local function nowPlaying()
if playingName == "" then
return ui.spacer({ height = 0 })
end
return ui.row({ gap = 8, align = "center" }, {
ui.label({ glyph = "music", color = "primary" }),
ui.label({ text = "Now playing: " .. playingName, color = "on_surface_variant", maxLines = 1, flexGrow = 1 }),
})
end
local function addForm()
if not showAddForm then
return ui.spacer({ height = 0 })
end
return ui.column({ gap = 8, align = "stretch" }, {
ui.input({
key = "add_name_" .. addNonce,
placeholder = "Name",
value = newName,
onChange = "onAddName",
onSubmit = "onAddConfirm",
}),
ui.input({
key = "add_url_" .. addNonce,
placeholder = "Stream URL",
value = newUrl,
onChange = "onAddUrl",
onSubmit = "onAddConfirm",
}),
ui.input({
key = "add_cat_" .. addNonce,
placeholder = "Category",
value = newCategory,
onChange = "onAddCategory",
onSubmit = "onAddConfirm",
}),
ui.row({ gap = 8, justify = "end" }, {
ui.button({ text = "Cancel", variant = "outline", controlSize = "sm", onClick = "onAddCancel" }),
ui.button({ text = "Add", variant = "primary", controlSize = "sm", onClick = "onAddConfirm" }),
}),
})
end
local function stationRow(station, slot)
local isPlaying = playingName == station.name
local isPreviewing = previewingName == station.name
return ui.row({ gap = 8, align = "center" }, {
ui.button({
glyph = "headphones",
variant = if isPreviewing then "primary" else "ghost",
controlSize = "sm",
tooltip = "Preview",
onClick = "onPreview" .. slot,
}),
ui.column({ flexGrow = 1, gap = 0 }, {
ui.label({ text = station.name, color = if isPlaying then "primary" else "on_surface", maxLines = 1 }),
ui.label({ text = station.category or "", fontSize = 12, color = "on_surface_variant", maxLines = 1 }),
}),
ui.button({ glyph = "player-play", variant = "ghost", controlSize = "sm", tooltip = "Play", onClick = "onPlay" .. slot }),
ui.button({ glyph = "trash", variant = "ghost", controlSize = "sm", tooltip = "Remove", onClick = "onRemove" .. slot }),
})
end
local function list()
local items = filtered()
if #items == 0 then
local text = if search ~= "" then "No stations match \"" .. search .. "\"" else "No stations"
return ui.label({ text = text, color = "on_surface_variant" })
end
pickSlots = {}
local rows = {}
for i, station in ipairs(items) do
local slot = i - 1
if slot >= MAX_ROWS then
break
end
pickSlots[slot] = station
table.insert(rows, stationRow(station, slot))
end
return ui.column({ gap = 6, align = "stretch" }, rows)
end
render = function()
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, {
header(),
nowPlaying(),
addForm(),
ui.scroll({ flexGrow = 1, gap = 6 }, { list() }),
}))
end
local function previewAt(slot)
local station = pickSlots[slot]
if not station then
return
end
if previewingName == station.name then
stopPreview()
else
preview(station)
end
render()
end
local function playAt(slot)
local station = pickSlots[slot]
if station then
play(station)
end
end
local function removeAt(slot)
local station = pickSlots[slot]
if station then
removeStation(station)
render()
end
end
function onOpen(_context)
loadStations()
search = ""
showAddForm = false
render()
end
function onClose()
stopPreview()
end
function onSearchChange(value)
search = value
render()
end
function onSearchSubmit(value)
search = value
local items = filtered()
if #items > 0 then
play(items[1])
else
render()
end
end
function onStopAll()
stopAll()
render()
end
function onToggleAdd()
showAddForm = not showAddForm
if not showAddForm then
newName = ""
newUrl = ""
newCategory = "Radio"
end
render()
end
function onAddName(value)
newName = value
end
function onAddUrl(value)
newUrl = value
end
function onAddCategory(value)
newCategory = value
end
function onAddConfirm()
local n = noctalia.string.trim(newName)
local u = noctalia.string.trim(newUrl)
local c = noctalia.string.trim(newCategory)
if c == "" then
c = "Radio"
end
if n ~= "" and u ~= "" then
addStation(n, u, c)
newName = ""
newUrl = ""
newCategory = "Radio"
addNonce += 1
showAddForm = false
render()
end
end
function onAddCancel()
newName = ""
newUrl = ""
newCategory = "Radio"
addNonce += 1
showAddForm = false
render()
end
function onCloseClicked()
panel.close()
end
function onPreview0() previewAt(0) end
function onPreview1() previewAt(1) end
function onPreview2() previewAt(2) end
function onPreview3() previewAt(3) end
function onPreview4() previewAt(4) end
function onPreview5() previewAt(5) end
function onPreview6() previewAt(6) end
function onPreview7() previewAt(7) end
function onPreview8() previewAt(8) end
function onPreview9() previewAt(9) end
function onPreview10() previewAt(10) end
function onPreview11() previewAt(11) end
function onPreview12() previewAt(12) end
function onPreview13() previewAt(13) end
function onPreview14() previewAt(14) end
function onPreview15() previewAt(15) end
function onPreview16() previewAt(16) end
function onPreview17() previewAt(17) end
function onPreview18() previewAt(18) end
function onPreview19() previewAt(19) end
function onPreview20() previewAt(20) end
function onPreview21() previewAt(21) end
function onPreview22() previewAt(22) end
function onPreview23() previewAt(23) end
function onPreview24() previewAt(24) end
function onPreview25() previewAt(25) end
function onPreview26() previewAt(26) end
function onPreview27() previewAt(27) end
function onPreview28() previewAt(28) end
function onPreview29() previewAt(29) end
function onPlay0() playAt(0) end
function onPlay1() playAt(1) end
function onPlay2() playAt(2) end
function onPlay3() playAt(3) end
function onPlay4() playAt(4) end
function onPlay5() playAt(5) end
function onPlay6() playAt(6) end
function onPlay7() playAt(7) end
function onPlay8() playAt(8) end
function onPlay9() playAt(9) end
function onPlay10() playAt(10) end
function onPlay11() playAt(11) end
function onPlay12() playAt(12) end
function onPlay13() playAt(13) end
function onPlay14() playAt(14) end
function onPlay15() playAt(15) end
function onPlay16() playAt(16) end
function onPlay17() playAt(17) end
function onPlay18() playAt(18) end
function onPlay19() playAt(19) end
function onPlay20() playAt(20) end
function onPlay21() playAt(21) end
function onPlay22() playAt(22) end
function onPlay23() playAt(23) end
function onPlay24() playAt(24) end
function onPlay25() playAt(25) end
function onPlay26() playAt(26) end
function onPlay27() playAt(27) end
function onPlay28() playAt(28) end
function onPlay29() playAt(29) end
function onRemove0() removeAt(0) end
function onRemove1() removeAt(1) end
function onRemove2() removeAt(2) end
function onRemove3() removeAt(3) end
function onRemove4() removeAt(4) end
function onRemove5() removeAt(5) end
function onRemove6() removeAt(6) end
function onRemove7() removeAt(7) end
function onRemove8() removeAt(8) end
function onRemove9() removeAt(9) end
function onRemove10() removeAt(10) end
function onRemove11() removeAt(11) end
function onRemove12() removeAt(12) end
function onRemove13() removeAt(13) end
function onRemove14() removeAt(14) end
function onRemove15() removeAt(15) end
function onRemove16() removeAt(16) end
function onRemove17() removeAt(17) end
function onRemove18() removeAt(18) end
function onRemove19() removeAt(19) end
function onRemove20() removeAt(20) end
function onRemove21() removeAt(21) end
function onRemove22() removeAt(22) end
function onRemove23() removeAt(23) end
function onRemove24() removeAt(24) end
function onRemove25() removeAt(25) end
function onRemove26() removeAt(26) end
function onRemove27() removeAt(27) end
function onRemove28() removeAt(28) end
function onRemove29() removeAt(29) endVerdict
Honestly I dont get the hype.
While everything indicates that Fable should nail every expecations for frontend tasks
I must say that this is barely usable and far away from pretty.
Additionally I lost functionality and even simple data that only had to be copy pasted.