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.
broken

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) end

Isn’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) end

Verdict

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.