Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- -- Musiclo music player made by timuzkas
- -- licensed under Creative Commons CC0
- -- Simple, sleek player for YT.
- -- //
- -- //
- -- Backend code from @terreng on github, using MIT license
- -- Frontend code by timuzkas, using CC0 license
- -- Transliterator by timuzkas, using CC0 license
- -- PrimeUI by JackMacWindows, using CC0 license
- local expect = require "cc.expect".expect
- -- PrimeUI by JackMacWindows
- -- Public domain/CC0
- -- Initialization code
- local PrimeUI = {}
- do
- local coros = {}
- local restoreCursor
- --- Adds a task to run in the main loop.
- ---@param func function The function to run, usually an `os.pullEvent` loop
- function PrimeUI.addTask(func)
- expect(1, func, "function")
- local t = {coro = coroutine.create(func)}
- coros[#coros+1] = t
- _, t.filter = coroutine.resume(t.coro)
- end
- --- Sends the provided arguments to the run loop, where they will be returned.
- ---@param ... any The parameters to send
- function PrimeUI.resolve(...)
- coroutine.yield(coros, ...)
- end
- --- Clears the screen and resets all components. Do not use any previously
- --- created components after calling this function.
- function PrimeUI.clear()
- -- Reset the screen.
- term.setCursorPos(1, 1)
- term.setCursorBlink(false)
- term.setBackgroundColor(colors.black)
- term.setTextColor(colors.white)
- term.clear()
- -- Reset the task list and cursor restore function.
- coros = {}
- restoreCursor = nil
- end
- --- Sets or clears the window that holds where the cursor should be.
- ---@param win window|nil The window to set as the active window
- function PrimeUI.setCursorWindow(win)
- expect(1, win, "table", "nil")
- restoreCursor = win and win.restoreCursor
- end
- --- Gets the absolute position of a coordinate relative to a window.
- ---@param win window The window to check
- ---@param x number The relative X position of the point
- ---@param y number The relative Y position of the point
- ---@return number x The absolute X position of the window
- ---@return number y The absolute Y position of the window
- function PrimeUI.getWindowPos(win, x, y)
- if win == term then return x, y end
- while win ~= term.native() and win ~= term.current() do
- if not win.getPosition then return x, y end
- local wx, wy = win.getPosition()
- x, y = x + wx - 1, y + wy - 1
- _, win = debug.getupvalue(select(2, debug.getupvalue(win.isColor, 1)), 1) -- gets the parent window through an upvalue
- end
- return x, y
- end
- --- Runs the main loop, returning information on an action.
- ---@return any ... The result of the coroutine that exited
- function PrimeUI.run()
- while true do
- -- Restore the cursor and wait for the next event.
- if restoreCursor then restoreCursor() end
- local ev = table.pack(os.pullEvent())
- -- Run all coroutines.
- for _, v in ipairs(coros) do
- if v.filter == nil or v.filter == ev[1] then
- -- Resume the coroutine, passing the current event.
- local res = table.pack(coroutine.resume(v.coro, table.unpack(ev, 1, ev.n)))
- -- If the call failed, bail out. Coroutines should never exit.
- if not res[1] then error(res[2], 2) end
- -- If the coroutine resolved, return its values.
- if res[2] == coros then return table.unpack(res, 3, res.n) end
- -- Set the next event filter.
- v.filter = res[2]
- end
- end
- end
- end
- end
- --- Draws a thin border around a screen region.
- ---@param win window The window to draw on
- ---@param x number The X coordinate of the inside of the box
- ---@param y number The Y coordinate of the inside of the box
- ---@param width number The width of the inner box
- ---@param height number The height of the inner box
- ---@param fgColor color|nil The color of the border (defaults to white)
- ---@param bgColor color|nil The color of the background (defaults to black)
- function PrimeUI.borderBox(win, x, y, width, height, fgColor, bgColor)
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, width, "number")
- expect(5, height, "number")
- fgColor = expect(6, fgColor, "number", "nil") or colors.white
- bgColor = expect(7, bgColor, "number", "nil") or colors.black
- -- Draw the top-left corner & top border.
- win.setBackgroundColor(bgColor)
- win.setTextColor(fgColor)
- win.setCursorPos(x - 1, y - 1)
- win.write("\x9C" .. ("\x8C"):rep(width))
- -- Draw the top-right corner.
- win.setBackgroundColor(fgColor)
- win.setTextColor(bgColor)
- win.write("\x93")
- -- Draw the right border.
- for i = 1, height do
- win.setCursorPos(win.getCursorPos() - 1, y + i - 1)
- win.write("\x95")
- end
- -- Draw the left border.
- win.setBackgroundColor(bgColor)
- win.setTextColor(fgColor)
- for i = 1, height do
- win.setCursorPos(x - 1, y + i - 1)
- win.write("\x95")
- end
- -- Draw the bottom border and corners.
- win.setCursorPos(x - 1, y + height)
- win.write("\x8D" .. ("\x8C"):rep(width) .. "\x8E")
- end
- --- Creates a clickable button on screen with text.
- ---@param win window The window to draw on
- ---@param x number The X position of the button
- ---@param y number The Y position of the button
- ---@param text string The text to draw on the button
- ---@param action function|string A function to call when clicked, or a string to send with a `run` event
- ---@param fgColor color|nil The color of the button text (defaults to white)
- ---@param bgColor color|nil The color of the button (defaults to light gray)
- ---@param clickedColor color|nil The color of the button when clicked (defaults to gray)
- ---@param periphName string|nil The name of the monitor peripheral, or nil (set if you're using a monitor - events will be filtered to that monitor)
- function PrimeUI.button(win, x, y, text, action, fgColor, bgColor, clickedColor, periphName)
- expect(1, win, "table")
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, text, "string")
- expect(5, action, "function", "string")
- fgColor = expect(6, fgColor, "number", "nil") or colors.white
- bgColor = expect(7, bgColor, "number", "nil") or colors.gray
- clickedColor = expect(8, clickedColor, "number", "nil") or colors.lightGray
- periphName = expect(9, periphName, "string", "nil")
- -- Draw the initial button.
- win.setCursorPos(x, y)
- win.setBackgroundColor(bgColor)
- win.setTextColor(fgColor)
- win.write(" " .. text .. " ")
- -- Get the screen position and add a click handler.
- PrimeUI.addTask(function()
- local buttonDown = false
- while true do
- local event, button, clickX, clickY = os.pullEvent()
- local screenX, screenY = PrimeUI.getWindowPos(win, x, y)
- if event == "mouse_click" and periphName == nil and button == 1 and clickX >= screenX and clickX < screenX + #text + 2 and clickY == screenY then
- -- Initiate a click action (but don't trigger until mouse up).
- buttonDown = true
- -- Redraw the button with the clicked background color.
- win.setCursorPos(x, y)
- win.setBackgroundColor(clickedColor)
- win.setTextColor(fgColor)
- win.write(" " .. text .. " ")
- elseif (event == "monitor_touch" and periphName == button and clickX >= screenX and clickX < screenX + #text + 2 and clickY == screenY)
- or (event == "mouse_up" and button == 1 and buttonDown) then
- -- Finish a click event.
- if clickX >= screenX and clickX < screenX + #text + 2 and clickY == screenY then
- -- Trigger the action.
- if type(action) == "string" then
- PrimeUI.resolve("button", action)
- else
- action()
- end
- end
- -- Redraw the original button state.
- win.setCursorPos(x, y)
- win.setBackgroundColor(bgColor)
- win.setTextColor(fgColor)
- win.write(" " .. text .. " ")
- buttonDown = false
- end
- end
- end)
- end
- --- Draws a line of text, centering it inside a box horizontally.
- ---@param win window The window to draw on
- ---@param x number The X position of the left side of the box
- ---@param y number The Y position of the box
- ---@param width number The width of the box to draw in
- ---@param text string The text to draw
- ---@param fgColor color|nil The color of the text (defaults to white)
- ---@param bgColor color|nil The color of the background (defaults to black)
- function PrimeUI.centerLabel(win, x, y, width, text, fgColor, bgColor)
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, width, "number")
- expect(5, text, "string")
- fgColor = expect(6, fgColor, "number", "nil") or colors.white
- bgColor = expect(7, bgColor, "number", "nil") or colors.black
- assert(#text <= width, "string is too long")
- win.setCursorPos(x + math.floor((width - #text) / 2), y)
- win.setTextColor(fgColor)
- win.setBackgroundColor(bgColor)
- win.write(text)
- end
- --- Creates a clickable region on screen without any content.
- ---@param win window The window to draw on
- ---@param x number The X position of the button
- ---@param y number The Y position of the button
- ---@param width number The width of the inner box
- ---@param height number The height of the inner box
- ---@param action function|string A function to call when clicked, or a string to send with a `run` event
- ---@param periphName string|nil The name of the monitor peripheral, or nil (set if you're using a monitor - events will be filtered to that monitor)
- function PrimeUI.clickRegion(win, x, y, width, height, action, periphName)
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, width, "number")
- expect(5, height, "number")
- expect(6, action, "function", "string")
- expect(7, periphName, "string", "nil")
- PrimeUI.addTask(function()
- -- Get the screen position and add a click handler.
- local screenX, screenY = PrimeUI.getWindowPos(win, x, y)
- while true do
- local event, button, clickX, clickY = os.pullEvent()
- if (event == "monitor_touch" and periphName == button)
- or (event == "mouse_click" and button == 1 and periphName == nil) then
- -- Finish a click event.
- if clickX >= screenX and clickX < screenX + width
- and clickY >= screenY and clickY < screenY + height then
- -- Trigger the action.
- if type(action) == "string" then
- PrimeUI.resolve("clickRegion", action)
- else
- action()
- end
- end
- end
- end
- end)
- end
- --- Draws a block of text inside a window with word wrapping, optionally resizing the window to fit.
- ---@param win window The window to draw in
- ---@param text string The text to draw
- ---@param resizeToFit boolean|nil Whether to resize the window to fit the text (defaults to false). This is useful for scroll boxes.
- ---@param fgColor color|nil The color of the text (defaults to white)
- ---@param bgColor color|nil The color of the background (defaults to black)
- ---@return number lines The total number of lines drawn
- function PrimeUI.drawText(win, text, resizeToFit, fgColor, bgColor)
- expect(1, win, "table")
- expect(2, text, "string")
- expect(3, resizeToFit, "boolean", "nil")
- fgColor = expect(4, fgColor, "number", "nil") or colors.white
- bgColor = expect(5, bgColor, "number", "nil") or colors.black
- -- Set colors.
- win.setBackgroundColor(bgColor)
- win.setTextColor(fgColor)
- -- Redirect to the window to use print on it.
- local old = term.redirect(win)
- -- Draw the text using print().
- local lines = print(text)
- -- Redirect back to the original terminal.
- term.redirect(old)
- -- Resize the window if desired.
- if resizeToFit then
- -- Get original parameters.
- local x, y = win.getPosition()
- local w = win.getSize()
- -- Resize the window.
- win.reposition(x, y, w, lines)
- end
- return lines
- end
- --- Draws a horizontal line at a position with the specified width.
- ---@param win window The window to draw on
- ---@param x number The X position of the left side of the line
- ---@param y number The Y position of the line
- ---@param width number The width/length of the line
- ---@param fgColor color|nil The color of the line (defaults to white)
- ---@param bgColor color|nil The color of the background (defaults to black)
- function PrimeUI.horizontalLine(win, x, y, width, fgColor, bgColor)
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, width, "number")
- fgColor = expect(5, fgColor, "number", "nil") or colors.white
- bgColor = expect(6, bgColor, "number", "nil") or colors.black
- -- Use drawing characters to draw a thin line.
- win.setCursorPos(x, y)
- win.setTextColor(fgColor)
- win.setBackgroundColor(bgColor)
- win.write(("\x8C"):rep(width))
- end
- --- Creates a text input box.
- ---@param win window The window to draw on
- ---@param x number The X position of the left side of the box
- ---@param y number The Y position of the box
- ---@param width number The width/length of the box
- ---@param action function|string A function or `run` event to call when the enter key is pressed
- ---@param fgColor color|nil The color of the text (defaults to white)
- ---@param bgColor color|nil The color of the background (defaults to black)
- ---@param replacement string|nil A character to replace typed characters with
- ---@param history string[]|nil A list of previous entries to provide
- ---@param completion function|nil A function to call to provide completion
- ---@param default string|nil A string to return if the box is empty
- function PrimeUI.inputBox(win, x, y, width, action, fgColor, bgColor, replacement, history, completion, default)
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, width, "number")
- expect(5, action, "function", "string")
- fgColor = expect(6, fgColor, "number", "nil") or colors.white
- bgColor = expect(7, bgColor, "number", "nil") or colors.black
- expect(8, replacement, "string", "nil")
- expect(9, history, "table", "nil")
- expect(10, completion, "function", "nil")
- expect(11, default, "string", "nil")
- -- Create a window to draw the input in.
- local box = window.create(win, x, y, width, 1)
- box.setTextColor(fgColor)
- box.setBackgroundColor(bgColor)
- box.clear()
- -- Call read() in a new coroutine.
- PrimeUI.addTask(function()
- -- We need a child coroutine to be able to redirect back to the window.
- local coro = coroutine.create(read)
- -- Run the function for the first time, redirecting to the window.
- local old = term.redirect(box)
- local ok, res = coroutine.resume(coro, replacement, history, completion, default)
- term.redirect(old)
- -- Run the coroutine until it finishes.
- while coroutine.status(coro) ~= "dead" do
- -- Get the next event.
- local ev = table.pack(os.pullEvent())
- -- Redirect and resume.
- old = term.redirect(box)
- ok, res = coroutine.resume(coro, table.unpack(ev, 1, ev.n))
- term.redirect(old)
- -- Pass any errors along.
- if not ok then error(res) end
- end
- -- Send the result to the receiver.
- if type(action) == "string" then PrimeUI.resolve("inputBox", action, res)
- else action(res) end
- -- Spin forever, because tasks cannot exit.
- while true do os.pullEvent() end
- end)
- end
- --- Runs a function or action repeatedly after a specified time period until canceled.
- ---@param time number The amount of time to wait for each time, in seconds
- ---@param action function|string The function to call when the timer completes, or a `run` event to send
- ---@return function cancel A function to cancel the timer
- function PrimeUI.interval(time, action)
- expect(1, time, "number")
- expect(2, action, "function", "string")
- -- Start the timer.
- local timer = os.startTimer(time)
- -- Add a task to wait for the timer.
- PrimeUI.addTask(function()
- while true do
- -- Wait for a timer event.
- local _, tm = os.pullEvent("timer")
- if tm == timer then
- -- Fire the timer action.
- local res
- if type(action) == "string" then PrimeUI.resolve("timeout", action)
- else res = action() end
- -- Check the return value and adjust time accordingly.
- if type(res) == "number" then time = res end
- -- Set a new timer if not canceled.
- if res ~= false then timer = os.startTimer(time) end
- end
- end
- end)
- -- Return a function to cancel the timer.
- return function() os.cancelTimer(timer) end
- end
- --- Adds an action to trigger when a key is pressed.
- ---@param key key The key to trigger on, from `keys.*`
- ---@param action function|string A function to call when clicked, or a string to use as a key for a `run` return event
- function PrimeUI.keyAction(key, action)
- expect(1, key, "number")
- expect(2, action, "function", "string")
- PrimeUI.addTask(function()
- while true do
- local _, param1 = os.pullEvent("key") -- wait for key
- if param1 == key then
- if type(action) == "string" then PrimeUI.resolve("keyAction", action)
- else action() end
- end
- end
- end)
- end
- --- Draws a line of text at a position.
- ---@param win window The window to draw on
- ---@param x number The X position of the left side of the text
- ---@param y number The Y position of the text
- ---@param text string The text to draw
- ---@param fgColor color|nil The color of the text (defaults to white)
- ---@param bgColor color|nil The color of the background (defaults to black)
- function PrimeUI.label(win, x, y, text, fgColor, bgColor)
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, text, "string")
- fgColor = expect(5, fgColor, "number", "nil") or colors.white
- bgColor = expect(6, bgColor, "number", "nil") or colors.black
- win.setCursorPos(x, y)
- win.setTextColor(fgColor)
- win.setBackgroundColor(bgColor)
- win.write(text)
- end
- --- Creates a progress bar, which can be updated by calling the returned function.
- ---@param win window The window to draw on
- ---@param x number The X position of the left side of the bar
- ---@param y number The Y position of the bar
- ---@param width number The width of the bar
- ---@param fgColor color|nil The color of the activated part of the bar (defaults to white)
- ---@param bgColor color|nil The color of the inactive part of the bar (defaults to black)
- ---@param useShade boolean|nil Whether to use shaded areas for the inactive part (defaults to false)
- ---@return function redraw A function to call to update the progress of the bar, taking a number from 0.0 to 1.0
- function PrimeUI.progressBar(win, x, y, width, fgColor, bgColor, useShade)
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, width, "number")
- fgColor = expect(5, fgColor, "number", "nil") or colors.white
- bgColor = expect(6, bgColor, "number", "nil") or colors.black
- expect(7, useShade, "boolean", "nil")
- local function redraw(progress)
- expect(1, progress, "number")
- if progress < 0 or progress > 1 then error("bad argument #1 (value out of range)", 2) end
- win.setCursorPos(x, y)
- win.setBackgroundColor(fgColor)
- win.write((" "):rep(math.floor(progress * width)))
- win.setBackgroundColor(bgColor)
- win.setTextColor(fgColor)
- win.write((useShade and "\x7F" or " "):rep(width - math.floor(progress * width)))
- end
- redraw(0)
- return redraw
- end
- --- Creates a scrollable window, which allows drawing large content in a small area.
- ---@param win window The parent window of the scroll box
- ---@param x number The X position of the box
- ---@param y number The Y position of the box
- ---@param width number The width of the box
- ---@param height number The height of the outer box
- ---@param innerHeight number The height of the inner scroll area
- ---@param allowArrowKeys boolean|nil Whether to allow arrow keys to scroll the box (defaults to true)
- ---@param showScrollIndicators boolean|nil Whether to show arrow indicators on the right side when scrolling is available (defaults to false)
- ---@param fgColor number|nil The color of scroll indicators (defaults to white)
- ---@param bgColor color|nil The color of the background (defaults to black)
- ---@return window inner The inner window to draw inside
- ---@return fun(pos:number) scroll A function to manually set the scroll position of the window
- function PrimeUI.scrollBox(win, x, y, width, height, innerHeight, allowArrowKeys, showScrollIndicators, fgColor, bgColor)
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, width, "number")
- expect(5, height, "number")
- expect(6, innerHeight, "number")
- expect(7, allowArrowKeys, "boolean", "nil")
- expect(8, showScrollIndicators, "boolean", "nil")
- fgColor = expect(9, fgColor, "number", "nil") or colors.white
- bgColor = expect(10, bgColor, "number", "nil") or colors.black
- if allowArrowKeys == nil then allowArrowKeys = true end
- -- Create the outer container box.
- local outer = window.create(win == term and term.current() or win, x, y, width, height)
- outer.setBackgroundColor(bgColor)
- outer.clear()
- -- Create the inner scrolling box.
- local inner = window.create(outer, 1, 1, width - (showScrollIndicators and 1 or 0), innerHeight)
- inner.setBackgroundColor(bgColor)
- inner.clear()
- -- Draw scroll indicators if desired.
- if showScrollIndicators then
- outer.setBackgroundColor(bgColor)
- outer.setTextColor(fgColor)
- outer.setCursorPos(width, height)
- outer.write(innerHeight > height and "\31" or " ")
- end
- -- Get the absolute position of the window.
- x, y = PrimeUI.getWindowPos(win, x, y)
- -- Add the scroll handler.
- local scrollPos = 1
- PrimeUI.addTask(function()
- while true do
- -- Wait for next event.
- local ev = table.pack(os.pullEvent())
- -- Update inner height in case it changed.
- innerHeight = select(2, inner.getSize())
- -- Check for scroll events and set direction.
- local dir
- if ev[1] == "key" and allowArrowKeys then
- if ev[2] == keys.up then dir = -1
- elseif ev[2] == keys.down then dir = 1 end
- elseif ev[1] == "mouse_scroll" and ev[3] >= x and ev[3] < x + width and ev[4] >= y and ev[4] < y + height then
- dir = ev[2]
- end
- -- If there's a scroll event, move the window vertically.
- if dir and (scrollPos + dir >= 1 and scrollPos + dir <= innerHeight - height) then
- scrollPos = scrollPos + dir
- inner.reposition(1, 2 - scrollPos)
- end
- -- Redraw scroll indicators if desired.
- if showScrollIndicators then
- outer.setBackgroundColor(bgColor)
- outer.setTextColor(fgColor)
- outer.setCursorPos(width, 1)
- outer.write(scrollPos > 1 and "\30" or " ")
- outer.setCursorPos(width, height)
- outer.write(scrollPos < innerHeight - height and "\31" or " ")
- end
- end
- end)
- -- Make a function to allow external scrolling.
- local function scroll(pos)
- expect(1, pos, "number")
- pos = math.floor(pos)
- expect.range(pos, 1, innerHeight - height)
- scrollPos = pos
- inner.reposition(1, 2 - scrollPos)
- if showScrollIndicators then
- outer.setBackgroundColor(bgColor)
- outer.setTextColor(fgColor)
- outer.setCursorPos(width, 1)
- outer.write(scrollPos > 1 and "\30" or " ")
- outer.setCursorPos(width, height)
- outer.write(scrollPos < innerHeight - height and "\31" or " ")
- end
- end
- return inner, scroll
- end
- --- Creates a list of entries that can each be selected.
- ---@param win window The window to draw on
- ---@param x number The X coordinate of the inside of the box
- ---@param y number The Y coordinate of the inside of the box
- ---@param width number The width of the inner box
- ---@param height number The height of the inner box
- ---@param entries string[] A list of entries to show
- ---@param action function|string A function or `run` event that's called when a selection is made
- ---@param selectChangeAction function|string|nil A function or `run` event that's called when the current selection is changed
- ---@param fgColor color|nil The color of the text (defaults to white)
- ---@param bgColor color|nil The color of the background (defaults to black)
- function PrimeUI.selectionBox(win, x, y, width, height, entries, action, selectChangeAction, fgColor, bgColor)
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, width, "number")
- expect(5, height, "number")
- expect(6, entries, "table")
- expect(7, action, "function", "string")
- expect(8, selectChangeAction, "function", "string", "nil")
- fgColor = expect(9, fgColor, "number", "nil") or colors.white
- bgColor = expect(10, bgColor, "number", "nil") or colors.black
- if #entries == 0 then error("bad argument #6 (table must not be empty)", 2) end
- for i, v in ipairs(entries) do
- if type(v) ~= "string" then error("bad item " .. i .. " in entries table (expected string, got " .. type(v), 2) end
- end
- local entrywin = window.create(win, x, y, width, height)
- local selection, scroll = 1, 1
- local function drawEntries()
- entrywin.setVisible(false)
- entrywin.setBackgroundColor(bgColor)
- entrywin.clear()
- for i = scroll, scroll + height - 1 do
- local e = entries[i]
- if not e then break end
- entrywin.setCursorPos(2, i - scroll + 1)
- if i == selection then
- entrywin.setBackgroundColor(fgColor)
- entrywin.setTextColor(bgColor)
- else
- entrywin.setBackgroundColor(bgColor)
- entrywin.setTextColor(fgColor)
- end
- entrywin.clearLine()
- entrywin.write(#e > width - 1 and e:sub(1, width - 4) .. "..." or e)
- end
- entrywin.setBackgroundColor(bgColor)
- entrywin.setTextColor(fgColor)
- entrywin.setCursorPos(width, 1)
- entrywin.write("\30")
- entrywin.setCursorPos(width, height)
- entrywin.write("\31")
- entrywin.setVisible(true)
- end
- drawEntries()
- PrimeUI.addTask(function()
- while true do
- local event, key, cx, cy = os.pullEvent()
- if event == "key" then
- if key == keys.down and selection < #entries then
- selection = selection + 1
- if selection > scroll + height - 1 then scroll = scroll + 1 end
- if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
- elseif selectChangeAction then selectChangeAction(selection) end
- drawEntries()
- elseif key == keys.up and selection > 1 then
- selection = selection - 1
- if selection < scroll then scroll = scroll - 1 end
- if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
- elseif selectChangeAction then selectChangeAction(selection) end
- drawEntries()
- elseif key == keys.enter then
- if type(action) == "string" then PrimeUI.resolve("selectionBox", action, entries[selection])
- else action(entries[selection]) end
- end
- elseif event == "mouse_click" and key == 1 then
- local wx, wy = PrimeUI.getWindowPos(entrywin, 1, 1)
- if cx == wx + width - 1 then
- if cy == wy and selection > 1 then
- selection = selection - 1
- if selection < scroll then scroll = scroll - 1 end
- if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
- elseif selectChangeAction then selectChangeAction(selection) end
- drawEntries()
- elseif cy == wy + height - 1 and selection < #entries then
- selection = selection + 1
- if selection > scroll + height - 1 then scroll = scroll + 1 end
- if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
- elseif selectChangeAction then selectChangeAction(selection) end
- drawEntries()
- end
- elseif cx >= wx and cx < wx + width - 1 and cy >= wy and cy < wy + height then
- local sel = scroll + (cy - wy)
- if sel == selection then
- if type(action) == "string" then PrimeUI.resolve("selectionBox", action, entries[selection])
- else action(entries[selection]) end
- else
- selection = sel
- if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
- elseif selectChangeAction then selectChangeAction(selection) end
- drawEntries()
- end
- end
- elseif event == "mouse_scroll" then
- local wx, wy = PrimeUI.getWindowPos(entrywin, 1, 1)
- if cx >= wx and cx < wx + width and cy >= wy and cy < wy + height then
- if key < 0 and selection > 1 then
- selection = selection - 1
- if selection < scroll then scroll = scroll - 1 end
- if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
- elseif selectChangeAction then selectChangeAction(selection) end
- drawEntries()
- elseif key > 0 and selection < #entries then
- selection = selection + 1
- if selection > scroll + height - 1 then scroll = scroll + 1 end
- if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
- elseif selectChangeAction then selectChangeAction(selection) end
- drawEntries()
- end
- end
- end
- end
- end)
- end
- --- Creates a text box that wraps text and can have its text modified later.
- ---@param win window The parent window of the text box
- ---@param x number The X position of the box
- ---@param y number The Y position of the box
- ---@param width number The width of the box
- ---@param height number The height of the box
- ---@param text string The initial text to draw
- ---@param fgColor color|nil The color of the text (defaults to white)
- ---@param bgColor color|nil The color of the background (defaults to black)
- ---@return function redraw A function to redraw the window with new contents
- function PrimeUI.textBox(win, x, y, width, height, text, fgColor, bgColor)
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, width, "number")
- expect(5, height, "number")
- expect(6, text, "string")
- fgColor = expect(7, fgColor, "number", "nil") or colors.white
- bgColor = expect(8, bgColor, "number", "nil") or colors.black
- local box = window.create(win, x, y, width, height)
- function box.getSize()
- return width, math.huge
- end
- local function redraw(_text)
- expect(1, _text, "string")
- box.setBackgroundColor(bgColor)
- box.setTextColor(fgColor)
- box.clear()
- box.setCursorPos(1, 1)
- local old = term.redirect(box)
- print(_text)
- term.redirect(old)
- end
- redraw(text)
- return redraw
- end
- --- Draws a vertical line at a position with the specified height.
- ---@param win window The window to draw on
- ---@param x number The X position of the line
- ---@param y number The Y position of the top of the line
- ---@param height number The height of the line
- ---@param right boolean|nil Whether to align the line to the right instead of the left (defaults to false)
- ---@param fgColor color|nil The color of the line (defaults to white)
- ---@param bgColor color|nil The color of the background (defaults to black)
- function PrimeUI.verticalLine(win, x, y, height, right, fgColor, bgColor)
- expect(1, win, "table")
- expect(2, x, "number")
- expect(3, y, "number")
- expect(4, height, "number")
- right = expect(5, right, "boolean", "nil") or false
- fgColor = expect(6, fgColor, "number", "nil") or colors.white
- bgColor = expect(7, bgColor, "number", "nil") or colors.black
- win.setTextColor(right and bgColor or fgColor)
- win.setBackgroundColor(right and fgColor or bgColor)
- for j = 1, height do
- win.setCursorPos(x, y + j - 1)
- win.write("\x95")
- end
- end
- ui = PrimeUI
- local function box(terminal, x, y, width, height, color, cornerStyle)
- cornerStyle = cornerStyle or "square"
- terminal.setBackgroundColor(color)
- if cornerStyle == "square" then
- for i = y, y + height - 1 do
- terminal.setCursorPos(x, i)
- terminal.write(string.rep(" ", width))
- end
- elseif cornerStyle == "round" then
- terminal.setCursorPos(x + 1, y)
- terminal.write(string.rep(" ", width - 2))
- for i = y + 1, y + height - 2 do
- terminal.setCursorPos(x, i)
- terminal.write(string.rep(" ", width))
- end
- terminal.setCursorPos(x + 1, y + height - 1)
- terminal.write(string.rep(" ", width - 2))
- end
- end
- ui.box = box
- local api_base_url = "https://ipod-2to6magyna-uc.a.run.app/"
- local width, height = term.getSize()
- local last_search_url = nil
- local search_results = nil
- local search_error = false
- local playing = false
- local queue = {}
- local now_playing = nil
- local looping = false
- local playing_id = nil
- local last_download_url = nil
- local playing_status = 0
- local player_handle = nil
- local start = nil
- local pcm = nil
- local size = nil
- local decoder = nil
- local needs_next_chunk = 0
- local buffer
- local is_loading = false
- local is_error = false
- local speakers = { peripheral.find("speaker") }
- if #speakers == 0 then
- error("No speakers attached. You need to connect a speaker to this computer. If this is an Advanced Noisy Pocket Computer, then this is a bug, and you should try restarting your Minecraft game.", 0)
- end
- local speaker = speakers[1]
- os.startTimer(1)
- -- ui helper functions
- local function playSong(song)
- now_playing = song
- playing = true
- playing_id = nil
- is_error = false
- end
- local function stopPlayback()
- playing = false
- speaker.stop()
- playing_id = nil
- playing_status = 0
- needs_next_chunk = 0
- is_loading = false
- if player_handle then
- player_handle.close()
- player_handle = nil
- end
- end
- local function togglePlayPause()
- if playing then
- stopPlayback()
- else
- if now_playing then
- playSong(now_playing)
- elseif #queue > 0 then
- local song = queue[1]
- table.remove(queue, 1)
- playSong(song)
- end
- end
- end
- local function skipSong()
- stopPlayback()
- if #queue > 0 then
- local song = queue[1]
- table.remove(queue, 1)
- playSong(song)
- else
- now_playing = nil
- end
- end
- local function toggleLoop()
- looping = not looping
- end
- local function addToQueue(song, position)
- if position then
- table.insert(queue, position, song)
- else
- table.insert(queue, song)
- end
- end
- local function removeFromQueue(position)
- if position and position >= 1 and position <= #queue then
- table.remove(queue, position)
- end
- end
- -- Find a song's position in the queue by its id
- local function findInQueue(songId)
- for i, s in ipairs(queue) do
- if s.id == songId then
- return i
- end
- end
- return nil
- end
- local function switchToNextSong()
- if #queue > 0 then
- now_playing = queue[1]
- table.remove(queue, 1)
- playing_id = nil
- else
- now_playing = nil
- playing = false
- playing_id = nil
- end
- os.queueEvent("song_changed") -- force redraw
- end
- local function clearQueue()
- queue = {}
- end
- local function searchMusic(query)
- last_search = query
- last_search_url = api_base_url .. "?search=" .. textutils.urlEncode(query)
- http.request(last_search_url)
- search_results = nil
- search_error = false
- end
- -- custom palette based on Spotify one.
- local original_palette = {}
- local function initCustomPallete()
- for i = 1, 16 do
- original_palette[i] = {term.getPaletteColor(2^(i-1))}
- end
- term.setPaletteColor(colors.green, 0x1ED760)
- term.setPaletteColor(colors.lightGray, 0xb3b3b3)
- term.setPaletteColor(colors.gray, 0x212121)
- term.setPaletteColor(colors.purple, 0x457e59)
- term.setPaletteColor(colors.magenta, 0x62d089)
- term.setPaletteColor(colors.brown, 0x2e2e2e)
- end
- local function fixString(str, limit)
- if not str then return "" end
- if #str <= limit then
- return str
- end
- return string.sub(str, 1, limit - 3) .. "..."
- end
- -- UI LOOP
- ui.page = 1
- local function redrawScreen()
- initCustomPallete()
- while true do
- ui.clear()
- ui.borderBox(term.current(), 3, 2, width - 4, 1, colors.gray)
- local isSmallScreen = width <= 30
- -- ── Top bar ──────────────────────────────────────────────────────────
- if now_playing then
- if playing then
- if isSmallScreen then
- ui.button(term.current(), 4, 2, "S", "stop", colors.white, colors.red, colors.orange)
- else
- ui.button(term.current(), 4, 2, "Stop", "stop", colors.white, colors.red, colors.orange)
- end
- else
- ui.button(term.current(), 4, 2, "\16", "resume", colors.white, colors.green, colors.lightGray)
- ui.button(term.current(), 8, 2, "R", "clear", colors.white, colors.red, colors.orange)
- end
- if not isSmallScreen then
- local statusPrefix = is_loading and "[..] " or is_error and "[ERR] " or ""
- ui.label(term.current(), 12, 2, fixString(statusPrefix .. now_playing.name, 20), colors.white)
- ui.label(term.current(), 12 + string.len(fixString(statusPrefix .. now_playing.name, 20)) + 1, 2, "| " .. fixString(now_playing.artist, 14), colors.lightGray)
- else
- ui.label(term.current(), 8, 2, fixString(now_playing.name, 16), colors.white)
- end
- else
- ui.label(term.current(), 4, 2, "Musiclo", colors.green)
- if not isSmallScreen then
- ui.label(term.current(), 4 + string.len("Musiclo") + 2, 2, "| CC:T music player made easy", colors.lightGray)
- else
- ui.label(term.current(), 4 + string.len("Musiclo") + 1, 2, "| CC:T player", colors.lightGray)
- end
- end
- local titleTruncateLimit = 41
- local artistTruncateLimit = 26
- if isSmallScreen then
- titleTruncateLimit = 19
- artistTruncateLimit = 15
- end
- -- ── Page 1: Queue ─────────────────────────────────────────────────────
- if ui.page == 1 then
- ui.borderBox(term.current(), 3, 5, width - 4, height - 6, colors.gray)
- ui.button(term.current(), width - 9, 4, "Search", "page.2", colors.white, colors.magenta, colors.purple)
- ui.keyAction(keys.enter, "page.2")
- ui.label(term.current(), 4, 4, "Queue", colors.white)
- ui.keyAction(keys.space, "toggle")
- -- Bottom controls
- if looping then
- ui.button(term.current(), 4, height - 1, "Loop", "loop", colors.white, colors.magenta, colors.purple)
- else
- ui.button(term.current(), 4, height - 1, "Loop", "loop", colors.white, colors.gray, colors.lightGray)
- end
- if #queue > 0 then
- ui.button(term.current(), 11, height - 1, "Skip", "skip", colors.white, colors.gray, colors.lightGray)
- if isSmallScreen then
- ui.button(term.current(), 18, height - 1, "Clr", "clear.q", colors.white, colors.red, colors.orange)
- else
- ui.button(term.current(), 18, height - 1, "Clear queue", "clear.q", colors.white, colors.red, colors.orange)
- end
- end
- -- Scroll area
- local scroller = ui.scrollBox(term.current(), 3, 5, width - 4, height - 6, 9000, true, true)
- local y = 2
- if #queue > 0 then
- for i, song in ipairs(queue) do
- ui.box(scroller, 1, y, width - 5, 5, colors.brown)
- ui.label(scroller, 2, y + 1, fixString(song.name, titleTruncateLimit), colors.white, colors.brown)
- ui.label(scroller, 2, y + 2, fixString(song.artist, artistTruncateLimit), colors.lightGray, colors.brown)
- local rowY = isSmallScreen and (y + 1) or y
- -- "Play now" button plays from queue, using queue index
- ui.button(scroller, width - 20, rowY + 2, "Play", "qplay." .. i, colors.white, colors.magenta, colors.purple)
- -- "Remove" removes from queue by index
- ui.button(scroller, width - 13, rowY + 2, "Rem", "qrem." .. i, colors.white, colors.red, colors.orange)
- y = y + 6
- end
- else
- ui.centerLabel(scroller, 1, 5, width - 4, "No songs in queue", colors.lightGray)
- ui.button(scroller, math.floor((width - 4 - string.len("Add song")) / 2) + 1, 7, "Add song", "page.2", colors.white, colors.gray, colors.lightGray)
- end
- -- ── Page 2: Search ────────────────────────────────────────────────────
- elseif ui.page == 2 then
- ui.borderBox(term.current(), 3, 5, width - 4, height - 6, colors.gray)
- ui.button(term.current(), width - 10, 4, "Go back", "page.1", colors.white, colors.gray, colors.lightGray)
- ui.label(term.current(), 4, 4, "Search", colors.white)
- ui.label(term.current(), 4, 6, "Search on Youtube...", colors.lightGray)
- ui.horizontalLine(term.current(), 3, 8, width - 4, colors.gray)
- local scroller = ui.scrollBox(term.current(), 3, 9, width - 4, height - 10, 9000, true, true)
- local y = 2
- if search_error then
- ui.centerLabel(scroller, 1, 5, width - 4, "Search failed!", colors.red)
- elseif search_results then
- if #search_results == 0 then
- ui.centerLabel(scroller, 1, 5, width - 4, "No results found", colors.lightGray)
- else
- for i, song in ipairs(search_results) do
- ui.box(scroller, 1, y, width - 6, 5, colors.brown)
- ui.label(scroller, 2, y + 1, fixString(song.name, titleTruncateLimit), colors.white, colors.brown)
- ui.label(scroller, 2, y + 2, fixString(song.artist, artistTruncateLimit), colors.lightGray, colors.brown)
- local rowY = isSmallScreen and (y + 1) or y
- -- Play immediately
- ui.button(scroller, width - 21, rowY + 2, "Play", "splay." .. i, colors.white, colors.magenta, colors.purple)
- -- Add or Remove from queue (check by song id)
- local queuePos = findInQueue(song.id)
- if queuePos then
- ui.button(scroller, width - 14, rowY + 2, "Remove", "srem." .. i, colors.white, colors.red, colors.orange)
- else
- ui.button(scroller, width - 14, rowY + 2, "Add", "sadd." .. i, colors.white, colors.gray, colors.lightGray)
- end
- y = y + 6
- end
- end
- end
- ui.inputBox(term.current(), 4, 7, width - 7, "search", colors.white, colors.gray)
- end
- -- ── Event handling ────────────────────────────────────────────────────
- local object, callback, text = ui.run()
- term.clear()
- term.setCursorPos(1, 1)
- if object == "rerender" then
- -- redraw
- elseif object == "button" or object == "keyAction" then
- if callback == "page.2" then
- ui.page = 2
- elseif callback == "page.1" then
- ui.page = 1
- -- ── Queue page actions ─────────────────────────────────────────
- elseif callback:sub(1, 5) == "qplay" then
- -- Play a specific song from the queue directly
- local index = tonumber(callback:sub(7))
- if index and queue[index] then
- local song = queue[index]
- table.remove(queue, index)
- playSong(song)
- end
- elseif callback:sub(1, 4) == "qrem" then
- -- Remove a song from the queue by index
- local index = tonumber(callback:sub(6))
- if index and queue[index] then
- removeFromQueue(index)
- end
- -- ── Search page actions ────────────────────────────────────────
- elseif callback:sub(1, 5) == "splay" then
- -- Play a search result immediately (goes to now_playing)
- local index = tonumber(callback:sub(7))
- if index and search_results and search_results[index] then
- playSong(search_results[index])
- ui.page = 1
- end
- elseif callback:sub(1, 4) == "sadd" then
- -- Add a search result to the queue
- local index = tonumber(callback:sub(6))
- if index and search_results and search_results[index] then
- local song = search_results[index]
- -- Only add if not already queued
- if not findInQueue(song.id) then
- addToQueue(song)
- end
- end
- elseif callback:sub(1, 4) == "srem" then
- -- Remove a search result's song from the queue (look up by id)
- local index = tonumber(callback:sub(6))
- if index and search_results and search_results[index] then
- local queuePos = findInQueue(search_results[index].id)
- if queuePos then
- removeFromQueue(queuePos)
- end
- end
- -- ── Playback controls ──────────────────────────────────────────
- elseif callback == "stop" then
- stopPlayback()
- elseif callback == "resume" then
- -- Resume the current song (restart it)
- if now_playing then
- playSong(now_playing)
- end
- elseif callback == "toggle" then
- togglePlayPause()
- elseif callback == "loop" then
- toggleLoop()
- elseif callback == "skip" then
- skipSong()
- elseif callback == "clear.q" then
- clearQueue()
- elseif callback == "clear" then
- -- Clear now playing entirely
- stopPlayback()
- now_playing = nil
- end
- elseif object == "inputBox" and callback == "search" then
- if text and text ~= "" then
- searchMusic(text)
- term.clear()
- local sx, sy = term.getSize()
- term.setTextColor(colors.lightGray)
- term.setCursorPos(math.floor(sx / 2 - #"Fetching..." / 2), math.floor(sy / 2))
- term.write("Fetching...")
- ui.searchDone = false
- repeat
- sleep(0.1)
- until ui.searchDone == true
- ui.searchDone = false
- end
- end
- end
- end
- local function audioLoop()
- local chunkQueue = {} -- pre-buffered decoded chunks
- local MAX_PREBUFFER = 3 -- keep up to 3 chunks decoded
- while true do
- sleep(0)
- if playing and now_playing then
- -- start new song
- if playing_id ~= now_playing.id then
- if player_handle then player_handle.close() player_handle = nil end
- chunkQueue = {} -- clear prebuffer on song change
- playing_id = now_playing.id
- last_download_url = api_base_url .. "?v=2&id=" .. textutils.urlEncode(playing_id)
- playing_status = 0
- needs_next_chunk = 1
- http.request({url = last_download_url, binary = true})
- is_loading = true
- is_error = false
- end
- -- feed speaker from prebuffer
- if #chunkQueue > 0 then
- local ok = speaker.playAudio(chunkQueue[1])
- if ok then
- table.remove(chunkQueue, 1)
- end
- end
- -- fill prebuffer from stream
- if playing_status == 1 and #chunkQueue < MAX_PREBUFFER then
- local chunk = player_handle.read(size)
- if not chunk then
- -- end of stream
- player_handle.close()
- player_handle = nil
- playing_status = 0
- is_loading = false
- if looping then
- playing_id = nil
- else
- switchToNextSong()
- end
- else
- if start then
- chunk, start = start .. chunk, nil
- size = size + 4
- end
- table.insert(chunkQueue, decoder(chunk))
- end
- end
- end
- end
- end
- -- Events
- local function eventLoop()
- while true do
- local event, param1, param2 = os.pullEvent()
- if event == "timer" then
- os.startTimer(1)
- end
- if event == "http_success" then
- local url = param1
- local handle = param2
- if url == last_search_url then
- local raw = handle.readAll()
- local parsed = textutils.unserialiseJSON(raw)
- if parsed then
- -- The API returns an extra first element, strip it
- table.remove(parsed, 1)
- search_results = parsed
- search_error = false
- else
- search_results = {}
- search_error = true
- end
- ui.searchDone = true
- end
- if url == last_download_url then
- player_handle = handle
- start = handle.read(4)
- size = 16 * 1024 - 4
- if start == "RIFF" then
- error("WAV not supported!")
- end
- playing_status = 1
- is_loading = false
- decoder = require "cc.audio.dfpwm".make_decoder()
- end
- end
- if event == "http_failure" then
- local url = param1
- if url == last_search_url then
- search_error = true
- search_results = nil
- ui.searchDone = true
- end
- if url == last_download_url then
- is_loading = false
- is_error = true
- playing_status = 0
- needs_next_chunk = 0
- playing_id = nil
- -- Skip to next in queue on error
- if #queue > 0 then
- now_playing = queue[1]
- table.remove(queue, 1)
- playing_id = nil
- is_error = false
- else
- now_playing = nil
- playing = false
- end
- end
- end
- if event == "song_changed" then
- PrimeUI.resolve("rerender", "song_changed")
- end
- end
- end
- parallel.waitForAny(audioLoop, eventLoop, redrawScreen)
- -- Cleanup
- for i = 1, 16 do
- local c = 2^(i-1)
- if original_palette[i] then
- term.setPaletteColor(c, table.unpack(original_palette[i]))
- end
- end
- term.setCursorBlink(false)
- term.clear()
- term.setCursorPos(1, 1)
Advertisement