· 6 months ago · Mar 30, 2025, 06:00 PM
1local expect = require "cc.expect".expect
2
3-- Initialization code
4local PrimeUI = {}
5do
6 local coros = {}
7 local restoreCursor
8
9 --- Adds a task to run in the main loop.
10 ---@param func function The function to run, usually an `os.pullEvent` loop
11 function PrimeUI.addTask(func)
12 expect(1, func, "function")
13 local t = {coro = coroutine.create(func)}
14 coros[#coros+1] = t
15 _, t.filter = coroutine.resume(t.coro)
16 end
17
18 --- Sends the provided arguments to the run loop, where they will be returned.
19 ---@param ... any The parameters to send
20 function PrimeUI.resolve(...)
21 coroutine.yield(coros, ...)
22 end
23
24 --- Clears the screen and resets all components. Do not use any previously
25 --- created components after calling this function.
26 function PrimeUI.clear()
27 -- Reset the screen.
28 term.setCursorPos(1, 1)
29 term.setCursorBlink(false)
30 term.setBackgroundColor(colors.black)
31 term.setTextColor(colors.white)
32 term.clear()
33 -- Reset the task list and cursor restore function.
34 coros = {}
35 restoreCursor = nil
36 end
37
38 --- Sets or clears the window that holds where the cursor should be.
39 ---@param win window|nil The window to set as the active window
40 function PrimeUI.setCursorWindow(win)
41 expect(1, win, "table", "nil")
42 restoreCursor = win and win.restoreCursor
43 end
44
45 --- Gets the absolute position of a coordinate relative to a window.
46 ---@param win window The window to check
47 ---@param x number The relative X position of the point
48 ---@param y number The relative Y position of the point
49 ---@return number x The absolute X position of the window
50 ---@return number y The absolute Y position of the window
51 function PrimeUI.getWindowPos(win, x, y)
52 if win == term then return x, y end
53 while win ~= term.native() and win ~= term.current() do
54 if not win.getPosition then return x, y end
55 local wx, wy = win.getPosition()
56 x, y = x + wx - 1, y + wy - 1
57 _, win = debug.getupvalue(select(2, debug.getupvalue(win.isColor, 1)), 1) -- gets the parent window through an upvalue
58 end
59 return x, y
60 end
61
62 --- Runs the main loop, returning information on an action.
63 ---@return any ... The result of the coroutine that exited
64 function PrimeUI.run()
65 while true do
66 -- Restore the cursor and wait for the next event.
67 if restoreCursor then restoreCursor() end
68 local ev = table.pack(os.pullEvent())
69 -- Run all coroutines.
70 for _, v in ipairs(coros) do
71 if v.filter == nil or v.filter == ev[1] then
72 -- Resume the coroutine, passing the current event.
73 local res = table.pack(coroutine.resume(v.coro, table.unpack(ev, 1, ev.n)))
74 -- If the call failed, bail out. Coroutines should never exit.
75 if not res[1] then error(res[2], 2) end
76 -- If the coroutine resolved, return its values.
77 if res[2] == coros then return table.unpack(res, 3, res.n) end
78 -- Set the next event filter.
79 v.filter = res[2]
80 end
81 end
82 end
83 end
84end
85
86--- Draws a thin border around a screen region.
87---@param win window The window to draw on
88---@param x number The X coordinate of the inside of the box
89---@param y number The Y coordinate of the inside of the box
90---@param width number The width of the inner box
91---@param height number The height of the inner box
92---@param fgColor color|nil The color of the border (defaults to white)
93---@param bgColor color|nil The color of the background (defaults to black)
94function PrimeUI.borderBox(win, x, y, width, height, fgColor, bgColor)
95 expect(1, win, "table")
96 expect(2, x, "number")
97 expect(3, y, "number")
98 expect(4, width, "number")
99 expect(5, height, "number")
100 fgColor = expect(6, fgColor, "number", "nil") or colors.white
101 bgColor = expect(7, bgColor, "number", "nil") or colors.black
102 -- Draw the top-left corner & top border.
103 win.setBackgroundColor(bgColor)
104 win.setTextColor(fgColor)
105 win.setCursorPos(x - 1, y - 1)
106 win.write("\x9C" .. ("\x8C"):rep(width))
107 -- Draw the top-right corner.
108 win.setBackgroundColor(fgColor)
109 win.setTextColor(bgColor)
110 win.write("\x93")
111 -- Draw the right border.
112 for i = 1, height do
113 win.setCursorPos(win.getCursorPos() - 1, y + i - 1)
114 win.write("\x95")
115 end
116 -- Draw the left border.
117 win.setBackgroundColor(bgColor)
118 win.setTextColor(fgColor)
119 for i = 1, height do
120 win.setCursorPos(x - 1, y + i - 1)
121 win.write("\x95")
122 end
123 -- Draw the bottom border and corners.
124 win.setCursorPos(x - 1, y + height)
125 win.write("\x8D" .. ("\x8C"):rep(width) .. "\x8E")
126end
127
128--- Creates a clickable button on screen with text.
129---@param win window The window to draw on
130---@param x number The X position of the button
131---@param y number The Y position of the button
132---@param text string The text to draw on the button
133---@param action function|string A function to call when clicked, or a string to send with a `run` event
134---@param fgColor color|nil The color of the button text (defaults to white)
135---@param bgColor color|nil The color of the button (defaults to light gray)
136---@param clickedColor color|nil The color of the button when clicked (defaults to gray)
137---@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)
138function PrimeUI.button(win, x, y, text, action, fgColor, bgColor, clickedColor, periphName)
139 expect(1, win, "table")
140 expect(1, win, "table")
141 expect(2, x, "number")
142 expect(3, y, "number")
143 expect(4, text, "string")
144 expect(5, action, "function", "string")
145 fgColor = expect(6, fgColor, "number", "nil") or colors.white
146 bgColor = expect(7, bgColor, "number", "nil") or colors.gray
147 clickedColor = expect(8, clickedColor, "number", "nil") or colors.lightGray
148 periphName = expect(9, periphName, "string", "nil")
149 -- Draw the initial button.
150 win.setCursorPos(x, y)
151 win.setBackgroundColor(bgColor)
152 win.setTextColor(fgColor)
153 win.write(" " .. text .. " ")
154 -- Get the screen position and add a click handler.
155 PrimeUI.addTask(function()
156 local buttonDown = false
157 while true do
158 local event, button, clickX, clickY = os.pullEvent()
159 local screenX, screenY = PrimeUI.getWindowPos(win, x, y)
160 if event == "mouse_click" and periphName == nil and button == 1 and clickX >= screenX and clickX < screenX + #text + 2 and clickY == screenY then
161 -- Initiate a click action (but don't trigger until mouse up).
162 buttonDown = true
163 -- Redraw the button with the clicked background color.
164 win.setCursorPos(x, y)
165 win.setBackgroundColor(clickedColor)
166 win.setTextColor(fgColor)
167 win.write(" " .. text .. " ")
168 elseif (event == "monitor_touch" and periphName == button and clickX >= screenX and clickX < screenX + #text + 2 and clickY == screenY)
169 or (event == "mouse_up" and button == 1 and buttonDown) then
170 -- Finish a click event.
171 if clickX >= screenX and clickX < screenX + #text + 2 and clickY == screenY then
172 -- Trigger the action.
173 if type(action) == "string" then
174 PrimeUI.resolve("button", action)
175 else
176 action()
177 end
178 end
179 -- Redraw the original button state.
180 win.setCursorPos(x, y)
181 win.setBackgroundColor(bgColor)
182 win.setTextColor(fgColor)
183 win.write(" " .. text .. " ")
184 end
185 end
186 end)
187end
188
189--- Draws a line of text, centering it inside a box horizontally.
190---@param win window The window to draw on
191---@param x number The X position of the left side of the box
192---@param y number The Y position of the box
193---@param width number The width of the box to draw in
194---@param text string The text to draw
195---@param fgColor color|nil The color of the text (defaults to white)
196---@param bgColor color|nil The color of the background (defaults to black)
197function PrimeUI.centerLabel(win, x, y, width, text, fgColor, bgColor)
198 expect(1, win, "table")
199 expect(2, x, "number")
200 expect(3, y, "number")
201 expect(4, width, "number")
202 expect(5, text, "string")
203 fgColor = expect(6, fgColor, "number", "nil") or colors.white
204 bgColor = expect(7, bgColor, "number", "nil") or colors.black
205 assert(#text <= width, "string is too long")
206 win.setCursorPos(x + math.floor((width - #text) / 2), y)
207 win.setTextColor(fgColor)
208 win.setBackgroundColor(bgColor)
209 win.write(text)
210end
211
212--- Creates a list of entries with toggleable check boxes.
213---@param win window The window to draw on
214---@param x number The X coordinate of the inside of the box
215---@param y number The Y coordinate of the inside of the box
216---@param width number The width of the inner box
217---@param height number The height of the inner box
218---@param selections table<string,string|boolean> A list of entries to show, where the value is whether the item is pre-selected (or `"R"` for required/forced selected)
219---@param action function|string|nil A function or `run` event that's called when a selection is made
220---@param fgColor color|nil The color of the text (defaults to white)
221---@param bgColor color|nil The color of the background (defaults to black)
222function PrimeUI.checkSelectionBox(win, x, y, width, height, selections, action, fgColor, bgColor)
223 expect(1, win, "table")
224 expect(2, x, "number")
225 expect(3, y, "number")
226 expect(4, width, "number")
227 expect(5, height, "number")
228 expect(6, selections, "table")
229 expect(7, action, "function", "string", "nil")
230 fgColor = expect(8, fgColor, "number", "nil") or colors.white
231 bgColor = expect(9, bgColor, "number", "nil") or colors.black
232 -- Calculate how many selections there are.
233 local nsel = 0
234 for _ in pairs(selections) do nsel = nsel + 1 end
235 -- Create the outer display box.
236 local outer = window.create(win, x, y, width, height)
237 outer.setBackgroundColor(bgColor)
238 outer.clear()
239 -- Create the inner scroll box.
240 local inner = window.create(outer, 1, 1, width - 1, nsel)
241 inner.setBackgroundColor(bgColor)
242 inner.setTextColor(fgColor)
243 inner.clear()
244 -- Draw each line in the window.
245 local lines = {}
246 local nl, selected = 1, 1
247 for k, v in pairs(selections) do
248 inner.setCursorPos(1, nl)
249 inner.write((v and (v == "R" and "[-] " or "[\xD7] ") or "[ ] ") .. k)
250 lines[nl] = {k, not not v}
251 nl = nl + 1
252 end
253 -- Draw a scroll arrow if there is scrolling.
254 if nsel > height then
255 outer.setCursorPos(width, height)
256 outer.setBackgroundColor(bgColor)
257 outer.setTextColor(fgColor)
258 outer.write("\31")
259 end
260 -- Set cursor blink status.
261 inner.setCursorPos(2, selected)
262 inner.setCursorBlink(true)
263 PrimeUI.setCursorWindow(inner)
264 -- Get screen coordinates & add run task.
265 local screenX, screenY = PrimeUI.getWindowPos(win, x, y)
266 PrimeUI.addTask(function()
267 local scrollPos = 1
268 while true do
269 -- Wait for an event.
270 local ev = table.pack(os.pullEvent())
271 -- Look for a scroll event or a selection event.
272 local dir
273 if ev[1] == "key" then
274 if ev[2] == keys.up then dir = -1
275 elseif ev[2] == keys.down then dir = 1
276 elseif ev[2] == keys.space and selections[lines[selected][1]] ~= "R" then
277 -- (Un)select the item.
278 lines[selected][2] = not lines[selected][2]
279 inner.setCursorPos(2, selected)
280 inner.write(lines[selected][2] and "\xD7" or " ")
281 -- Call the action if passed; otherwise, set the original table.
282 if type(action) == "string" then PrimeUI.resolve("checkSelectionBox", action, lines[selected][1], lines[selected][2])
283 elseif action then action(lines[selected][1], lines[selected][2])
284 else selections[lines[selected][1]] = lines[selected][2] end
285 -- Redraw all lines in case of changes.
286 for i, v in ipairs(lines) do
287 local vv = selections[v[1]] == "R" and "R" or v[2]
288 inner.setCursorPos(2, i)
289 inner.write((vv and (vv == "R" and "-" or "\xD7") or " "))
290 end
291 inner.setCursorPos(2, selected)
292 end
293 elseif ev[1] == "mouse_scroll" and ev[3] >= screenX and ev[3] < screenX + width and ev[4] >= screenY and ev[4] < screenY + height then
294 dir = ev[2]
295 end
296 -- Scroll the screen if required.
297 if dir and (selected + dir >= 1 and selected + dir <= nsel) then
298 selected = selected + dir
299 if selected - scrollPos < 0 or selected - scrollPos >= height then
300 scrollPos = scrollPos + dir
301 inner.reposition(1, 2 - scrollPos)
302 end
303 inner.setCursorPos(2, selected)
304 end
305 -- Redraw scroll arrows and reset cursor.
306 outer.setCursorPos(width, 1)
307 outer.write(scrollPos > 1 and "\30" or " ")
308 outer.setCursorPos(width, height)
309 outer.write(scrollPos < nsel - height + 1 and "\31" or " ")
310 inner.restoreCursor()
311 end
312 end)
313end
314
315--- Creates a clickable region on screen without any content.
316---@param win window The window to draw on
317---@param x number The X position of the button
318---@param y number The Y position of the button
319---@param width number The width of the inner box
320---@param height number The height of the inner box
321---@param action function|string A function to call when clicked, or a string to send with a `run` event
322---@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)
323function PrimeUI.clickRegion(win, x, y, width, height, action, periphName)
324 expect(1, win, "table")
325 expect(2, x, "number")
326 expect(3, y, "number")
327 expect(4, width, "number")
328 expect(5, height, "number")
329 expect(6, action, "function", "string")
330 expect(7, periphName, "string", "nil")
331 PrimeUI.addTask(function()
332 -- Get the screen position and add a click handler.
333 local screenX, screenY = PrimeUI.getWindowPos(win, x, y)
334 local buttonDown = false
335 while true do
336 local event, button, clickX, clickY = os.pullEvent()
337 if (event == "monitor_touch" and periphName == button)
338 or (event == "mouse_click" and button == 1 and periphName == nil) then
339 -- Finish a click event.
340 if clickX >= screenX and clickX < screenX + width
341 and clickY >= screenY and clickY < screenY + height then
342 -- Trigger the action.
343 if type(action) == "string" then
344 PrimeUI.resolve("clickRegion", action)
345 else
346 action()
347 end
348 end
349 end
350 end
351 end)
352end
353
354--- Draws a NFT-formatted image to the screen.
355---@param win window The window to draw on
356---@param x number The X position of the top left corner of the image
357---@param y number The Y position of the top left corner of the image
358---@param data string|table The path to the image to load, or the image data itself
359function PrimeUI.drawNFT(win, x, y, data)
360 expect(1, win, "table")
361 expect(2, x, "number")
362 expect(3, y, "number")
363 expect(4, data, "string", "table")
364 -- Load the image file if a string was passed using nft.load.
365 if type(data) == "string" then
366 data = assert(nft.load("data/example.nft"), "File is not a valid NFT file")
367 end
368 nft.draw(data, x, y , win)
369end
370
371--- Draws a block of text inside a window with word wrapping, optionally resizing the window to fit.
372---@param win window The window to draw in
373---@param text string The text to draw
374---@param resizeToFit boolean|nil Whether to resize the window to fit the text (defaults to false). This is useful for scroll boxes.
375---@param fgColor color|nil The color of the text (defaults to white)
376---@param bgColor color|nil The color of the background (defaults to black)
377---@return number lines The total number of lines drawn
378function PrimeUI.drawText(win, text, resizeToFit, fgColor, bgColor)
379 expect(1, win, "table")
380 expect(2, text, "string")
381 expect(3, resizeToFit, "boolean", "nil")
382 fgColor = expect(4, fgColor, "number", "nil") or colors.white
383 bgColor = expect(5, bgColor, "number", "nil") or colors.black
384 -- Set colors.
385 win.setBackgroundColor(bgColor)
386 win.setTextColor(fgColor)
387 -- Redirect to the window to use print on it.
388 local old = term.redirect(win)
389 -- Draw the text using print().
390 local lines = print(text)
391 -- Redirect back to the original terminal.
392 term.redirect(old)
393 -- Resize the window if desired.
394 if resizeToFit then
395 -- Get original parameters.
396 local x, y = win.getPosition()
397 local w = win.getSize()
398 -- Resize the window.
399 win.reposition(x, y, w, lines)
400 end
401 return lines
402end
403
404--- Draws a horizontal line at a position with the specified width.
405---@param win window The window to draw on
406---@param x number The X position of the left side of the line
407---@param y number The Y position of the line
408---@param width number The width/length of the line
409---@param fgColor color|nil The color of the line (defaults to white)
410---@param bgColor color|nil The color of the background (defaults to black)
411function PrimeUI.horizontalLine(win, x, y, width, fgColor, bgColor)
412 expect(1, win, "table")
413 expect(2, x, "number")
414 expect(3, y, "number")
415 expect(4, width, "number")
416 fgColor = expect(5, fgColor, "number", "nil") or colors.white
417 bgColor = expect(6, bgColor, "number", "nil") or colors.black
418 -- Use drawing characters to draw a thin line.
419 win.setCursorPos(x, y)
420 win.setTextColor(fgColor)
421 win.setBackgroundColor(bgColor)
422 win.write(("\x8C"):rep(width))
423end
424
425--- Creates a text input box.
426---@param win window The window to draw on
427---@param x number The X position of the left side of the box
428---@param y number The Y position of the box
429---@param width number The width/length of the box
430---@param action function|string A function or `run` event to call when the enter key is pressed
431---@param fgColor color|nil The color of the text (defaults to white)
432---@param bgColor color|nil The color of the background (defaults to black)
433---@param replacement string|nil A character to replace typed characters with
434---@param history string[]|nil A list of previous entries to provide
435---@param completion function|nil A function to call to provide completion
436---@param default string|nil A string to return if the box is empty
437function PrimeUI.inputBox(win, x, y, width, action, fgColor, bgColor, replacement, history, completion, default)
438 expect(1, win, "table")
439 expect(2, x, "number")
440 expect(3, y, "number")
441 expect(4, width, "number")
442 expect(5, action, "function", "string")
443 fgColor = expect(6, fgColor, "number", "nil") or colors.white
444 bgColor = expect(7, bgColor, "number", "nil") or colors.black
445 expect(8, replacement, "string", "nil")
446 expect(9, history, "table", "nil")
447 expect(10, completion, "function", "nil")
448 expect(11, default, "string", "nil")
449 -- Create a window to draw the input in.
450 local box = window.create(win, x, y, width, 1)
451 box.setTextColor(fgColor)
452 box.setBackgroundColor(bgColor)
453 box.clear()
454 -- Call read() in a new coroutine.
455 PrimeUI.addTask(function()
456 -- We need a child coroutine to be able to redirect back to the window.
457 local coro = coroutine.create(read)
458 -- Run the function for the first time, redirecting to the window.
459 local old = term.redirect(box)
460 local ok, res = coroutine.resume(coro, replacement, history, completion, default)
461 term.redirect(old)
462 -- Run the coroutine until it finishes.
463 while coroutine.status(coro) ~= "dead" do
464 -- Get the next event.
465 local ev = table.pack(os.pullEvent())
466 -- Redirect and resume.
467 old = term.redirect(box)
468 ok, res = coroutine.resume(coro, table.unpack(ev, 1, ev.n))
469 term.redirect(old)
470 -- Pass any errors along.
471 if not ok then error(res) end
472 end
473 -- Send the result to the receiver.
474 if type(action) == "string" then PrimeUI.resolve("inputBox", action, res)
475 else action(res) end
476 -- Spin forever, because tasks cannot exit.
477 while true do os.pullEvent() end
478 end)
479end
480
481--- Runs a function or action repeatedly after a specified time period until canceled.
482--- If a function is passed as an action, it may return a number to change the
483--- period, or `false` to stop it.
484---@param time number The amount of time to wait for each time, in seconds
485---@param action function|string The function to call when the timer completes, or a `run` event to send
486---@return function cancel A function to cancel the timer
487function PrimeUI.interval(time, action)
488 expect(1, time, "number")
489 expect(2, action, "function", "string")
490 -- Start the timer.
491 local timer = os.startTimer(time)
492 -- Add a task to wait for the timer.
493 PrimeUI.addTask(function()
494 while true do
495 -- Wait for a timer event.
496 local _, tm = os.pullEvent("timer")
497 if tm == timer then
498 -- Fire the timer action.
499 local res
500 if type(action) == "string" then PrimeUI.resolve("timeout", action)
501 else res = action() end
502 -- Check the return value and adjust time accordingly.
503 if type(res) == "number" then time = res end
504 -- Set a new timer if not canceled.
505 if res ~= false then timer = os.startTimer(time) end
506 end
507 end
508 end)
509 -- Return a function to cancel the timer.
510 return function() os.cancelTimer(timer) end
511end
512
513--- Adds an action to trigger when a key is pressed.
514---@param key key The key to trigger on, from `keys.*`
515---@param action function|string A function to call when clicked, or a string to use as a key for a `run` return event
516function PrimeUI.keyAction(key, action)
517 expect(1, key, "number")
518 expect(2, action, "function", "string")
519 PrimeUI.addTask(function()
520 while true do
521 local _, param1 = os.pullEvent("key") -- wait for key
522 if param1 == key then
523 if type(action) == "string" then PrimeUI.resolve("keyAction", action)
524 else action() end
525 end
526 end
527 end)
528end
529
530--- Draws a line of text at a position.
531---@param win window The window to draw on
532---@param x number The X position of the left side of the text
533---@param y number The Y position of the text
534---@param text string The text to draw
535---@param fgColor color|nil The color of the text (defaults to white)
536---@param bgColor color|nil The color of the background (defaults to black)
537function PrimeUI.label(win, x, y, text, fgColor, bgColor)
538 expect(1, win, "table")
539 expect(2, x, "number")
540 expect(3, y, "number")
541 expect(4, text, "string")
542 fgColor = expect(5, fgColor, "number", "nil") or colors.white
543 bgColor = expect(6, bgColor, "number", "nil") or colors.black
544 win.setCursorPos(x, y)
545 win.setTextColor(fgColor)
546 win.setBackgroundColor(bgColor)
547 win.write(text)
548end
549
550--- Creates a progress bar, which can be updated by calling the returned function.
551---@param win window The window to draw on
552---@param x number The X position of the left side of the bar
553---@param y number The Y position of the bar
554---@param width number The width of the bar
555---@param fgColor color|nil The color of the activated part of the bar (defaults to white)
556---@param bgColor color|nil The color of the inactive part of the bar (defaults to black)
557---@param useShade boolean|nil Whether to use shaded areas for the inactive part (defaults to false)
558---@return function redraw A function to call to update the progress of the bar, taking a number from 0.0 to 1.0
559function PrimeUI.progressBar(win, x, y, width, fgColor, bgColor, useShade)
560 expect(1, win, "table")
561 expect(2, x, "number")
562 expect(3, y, "number")
563 expect(4, width, "number")
564 fgColor = expect(5, fgColor, "number", "nil") or colors.white
565 bgColor = expect(6, bgColor, "number", "nil") or colors.black
566 expect(7, useShade, "boolean", "nil")
567 local function redraw(progress)
568 expect(1, progress, "number")
569 if progress < 0 or progress > 1 then error("bad argument #1 (value out of range)", 2) end
570 -- Draw the active part of the bar.
571 win.setCursorPos(x, y)
572 win.setBackgroundColor(bgColor)
573 win.setBackgroundColor(fgColor)
574 win.write((" "):rep(math.floor(progress * width)))
575 -- Draw the inactive part of the bar, using shade if desired.
576 win.setBackgroundColor(bgColor)
577 win.setTextColor(fgColor)
578 win.write((useShade and "\x7F" or " "):rep(width - math.floor(progress * width)))
579 end
580 redraw(0)
581 return redraw
582end
583
584--- Creates a scrollable window, which allows drawing large content in a small area.
585---@param win window The parent window of the scroll box
586---@param x number The X position of the box
587---@param y number The Y position of the box
588---@param width number The width of the box
589---@param height number The height of the outer box
590---@param innerHeight number The height of the inner scroll area
591---@param allowArrowKeys boolean|nil Whether to allow arrow keys to scroll the box (defaults to true)
592---@param showScrollIndicators boolean|nil Whether to show arrow indicators on the right side when scrolling is available, which reduces the inner width by 1 (defaults to false)
593---@param fgColor number|nil The color of scroll indicators (defaults to white)
594---@param bgColor color|nil The color of the background (defaults to black)
595---@return window inner The inner window to draw inside
596---@return fun(pos:number) scroll A function to manually set the scroll position of the window
597function PrimeUI.scrollBox(win, x, y, width, height, innerHeight, allowArrowKeys, showScrollIndicators, fgColor, bgColor)
598 expect(1, win, "table")
599 expect(2, x, "number")
600 expect(3, y, "number")
601 expect(4, width, "number")
602 expect(5, height, "number")
603 expect(6, innerHeight, "number")
604 expect(7, allowArrowKeys, "boolean", "nil")
605 expect(8, showScrollIndicators, "boolean", "nil")
606 fgColor = expect(9, fgColor, "number", "nil") or colors.white
607 bgColor = expect(10, bgColor, "number", "nil") or colors.black
608 if allowArrowKeys == nil then allowArrowKeys = true end
609 -- Create the outer container box.
610 local outer = window.create(win == term and term.current() or win, x, y, width, height)
611 outer.setBackgroundColor(bgColor)
612 outer.clear()
613 -- Create the inner scrolling box.
614 local inner = window.create(outer, 1, 1, width - (showScrollIndicators and 1 or 0), innerHeight)
615 inner.setBackgroundColor(bgColor)
616 inner.clear()
617 -- Draw scroll indicators if desired.
618 if showScrollIndicators then
619 outer.setBackgroundColor(bgColor)
620 outer.setTextColor(fgColor)
621 outer.setCursorPos(width, height)
622 outer.write(innerHeight > height and "\31" or " ")
623 end
624 -- Get the absolute position of the window.
625 x, y = PrimeUI.getWindowPos(win, x, y)
626 -- Add the scroll handler.
627 local scrollPos = 1
628
629 -- Store the original event filter function
630 local originalEventFilter = PrimeUI.eventFilter
631
632 -- Replace the event filter to adjust mouse coordinates for buttons inside the scroll box
633 PrimeUI.eventFilter = function(event, ...)
634 if event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" then
635 local _, mouseX, mouseY = ...
636
637 -- Check if the click is within the scroll box boundaries
638 if mouseX >= x and mouseX < x + width and mouseY >= y and mouseY < y + height then
639 -- Adjust the Y coordinate based on scroll position
640 local adjustedY = mouseY + scrollPos - 1
641
642 -- Call the original event filter with adjusted coordinates
643 return originalEventFilter(event, _, mouseX, adjustedY, select(4, ...))
644 end
645 end
646
647 -- For other events, use the original filter
648 return originalEventFilter(event, ...)
649 end
650
651 PrimeUI.addTask(function()
652 while true do
653 -- Wait for next event.
654 local ev = table.pack(os.pullEvent())
655 -- Update inner height in case it changed.
656 innerHeight = select(2, inner.getSize())
657 -- Check for scroll events and set direction.
658 local dir
659 if ev[1] == "key" and allowArrowKeys then
660 if ev[2] == keys.up then dir = -1
661 elseif ev[2] == keys.down then dir = 1 end
662 elseif ev[1] == "mouse_scroll" and ev[3] >= x and ev[3] < x + width and ev[4] >= y and ev[4] < y + height then
663 dir = ev[2]
664 end
665 -- If there's a scroll event, move the window vertically.
666 if dir and (scrollPos + dir >= 1 and scrollPos + dir <= innerHeight - height) then
667 scrollPos = scrollPos + dir
668 inner.reposition(1, 2 - scrollPos)
669 end
670 -- Redraw scroll indicators if desired.
671 if showScrollIndicators then
672 outer.setBackgroundColor(bgColor)
673 outer.setTextColor(fgColor)
674 outer.setCursorPos(width, 1)
675 outer.write(scrollPos > 1 and "\30" or " ")
676 outer.setCursorPos(width, height)
677 outer.write(scrollPos < innerHeight - height and "\31" or " ")
678 end
679 end
680 end)
681
682 -- Make a function to allow external scrolling.
683 local function scroll(pos)
684 expect(1, pos, "number")
685 pos = math.floor(pos)
686 expect.range(pos, 1, innerHeight - height)
687 -- Scroll the window.
688 scrollPos = pos
689 inner.reposition(1, 2 - scrollPos)
690 -- Redraw scroll indicators if desired.
691 if showScrollIndicators then
692 outer.setBackgroundColor(bgColor)
693 outer.setTextColor(fgColor)
694 outer.setCursorPos(width, 1)
695 outer.write(scrollPos > 1 and "\30" or " ")
696 outer.setCursorPos(width, height)
697 outer.write(scrollPos < innerHeight - height and "\31" or " ")
698 end
699 end
700
701 -- Add a cleanup task to restore the original event filter when the scroll box is destroyed
702 PrimeUI.addTask(function()
703 while true do
704 local event = os.pullEvent("term_resize")
705 -- Check if the outer window still exists
706 if not outer.isColor then
707 -- Restore the original event filter
708 PrimeUI.eventFilter = originalEventFilter
709 return
710 end
711 end
712 end)
713
714 return inner, scroll
715end
716
717--- Creates a list of entries that can each be selected.
718---@param win window The window to draw on
719---@param x number The X coordinate of the inside of the box
720---@param y number The Y coordinate of the inside of the box
721---@param width number The width of the inner box
722---@param height number The height of the inner box
723---@param entries string[] A list of entries to show, where the value is whether the item is pre-selected (or `"R"` for required/forced selected)
724---@param action function|string A function or `run` event that's called when a selection is made
725---@param selectChangeAction function|string|nil A function or `run` event that's called when the current selection is changed
726---@param fgColor color|nil The color of the text (defaults to white)
727---@param bgColor color|nil The color of the background (defaults to black)
728function PrimeUI.selectionBox(win, x, y, width, height, entries, action, selectChangeAction, fgColor, bgColor)
729 expect(1, win, "table")
730 expect(2, x, "number")
731 expect(3, y, "number")
732 expect(4, width, "number")
733 expect(5, height, "number")
734 expect(6, entries, "table")
735 expect(7, action, "function", "string")
736 expect(8, selectChangeAction, "function", "string", "nil")
737 fgColor = expect(9, fgColor, "number", "nil") or colors.white
738 bgColor = expect(10, bgColor, "number", "nil") or colors.black
739 -- Check that all entries are strings.
740 if #entries == 0 then error("bad argument #6 (table must not be empty)", 2) end
741 for i, v in ipairs(entries) do
742 if type(v) ~= "string" then error("bad item " .. i .. " in entries table (expected string, got " .. type(v), 2) end
743 end
744 -- Create container window.
745 local entrywin = window.create(win, x, y, width, height)
746 local selection, scroll = 1, 1
747 -- Create a function to redraw the entries on screen.
748 local function drawEntries()
749 -- Clear and set invisible for performance.
750 entrywin.setVisible(false)
751 entrywin.setBackgroundColor(bgColor)
752 entrywin.clear()
753 -- Draw each entry in the scrolled region.
754 for i = scroll, scroll + height - 1 do
755 -- Get the entry; stop if there's no more.
756 local e = entries[i]
757 if not e then break end
758 -- Set the colors: invert if selected.
759 entrywin.setCursorPos(2, i - scroll + 1)
760 if i == selection then
761 entrywin.setBackgroundColor(fgColor)
762 entrywin.setTextColor(bgColor)
763 else
764 entrywin.setBackgroundColor(bgColor)
765 entrywin.setTextColor(fgColor)
766 end
767 -- Draw the selection.
768 entrywin.clearLine()
769 entrywin.write(#e > width - 1 and e:sub(1, width - 4) .. "..." or e)
770 end
771 -- Draw scroll arrows.
772 entrywin.setBackgroundColor(bgColor)
773 entrywin.setTextColor(fgColor)
774 entrywin.setCursorPos(width, 1)
775 entrywin.write("\30")
776 entrywin.setCursorPos(width, height)
777 entrywin.write("\31")
778 -- Send updates to the screen.
779 entrywin.setVisible(true)
780 end
781 -- Draw first screen.
782 drawEntries()
783 -- Add a task for selection keys.
784 PrimeUI.addTask(function()
785 while true do
786 local event, key, cx, cy = os.pullEvent()
787 if event == "key" then
788 if key == keys.down and selection < #entries then
789 -- Move selection down.
790 selection = selection + 1
791 if selection > scroll + height - 1 then scroll = scroll + 1 end
792 -- Send action if necessary.
793 if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
794 elseif selectChangeAction then selectChangeAction(selection) end
795 -- Redraw screen.
796 drawEntries()
797 elseif key == keys.up and selection > 1 then
798 -- Move selection up.
799 selection = selection - 1
800 if selection < scroll then scroll = scroll - 1 end
801 -- Send action if necessary.
802 if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
803 elseif selectChangeAction then selectChangeAction(selection) end
804 -- Redraw screen.
805 drawEntries()
806 elseif key == keys.enter then
807 -- Select the entry: send the action.
808 if type(action) == "string" then PrimeUI.resolve("selectionBox", action, entries[selection])
809 else action(entries[selection]) end
810 end
811 elseif event == "mouse_click" and key == 1 then
812 -- Handle clicking the scroll arrows.
813 local wx, wy = PrimeUI.getWindowPos(entrywin, 1, 1)
814 if cx == wx + width - 1 then
815 if cy == wy and selection > 1 then
816 -- Move selection up.
817 selection = selection - 1
818 if selection < scroll then scroll = scroll - 1 end
819 -- Send action if necessary.
820 if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
821 elseif selectChangeAction then selectChangeAction(selection) end
822 -- Redraw screen.
823 drawEntries()
824 elseif cy == wy + height - 1 and selection < #entries then
825 -- Move selection down.
826 selection = selection + 1
827 if selection > scroll + height - 1 then scroll = scroll + 1 end
828 -- Send action if necessary.
829 if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
830 elseif selectChangeAction then selectChangeAction(selection) end
831 -- Redraw screen.
832 drawEntries()
833 end
834 elseif cx >= wx and cx < wx + width - 1 and cy >= wy and cy < wy + height then
835 local sel = scroll + (cy - wy)
836 if sel == selection then
837 -- Select the entry: send the action.
838 if type(action) == "string" then PrimeUI.resolve("selectionBox", action, entries[selection])
839 else action(entries[selection]) end
840 else
841 selection = sel
842 -- Send action if necessary.
843 if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
844 elseif selectChangeAction then selectChangeAction(selection) end
845 -- Redraw screen.
846 drawEntries()
847 end
848 end
849 elseif event == "mouse_scroll" then
850 -- Handle mouse scrolling.
851 local wx, wy = PrimeUI.getWindowPos(entrywin, 1, 1)
852 if cx >= wx and cx < wx + width and cy >= wy and cy < wy + height then
853 if key < 0 and selection > 1 then
854 -- Move selection up.
855 selection = selection - 1
856 if selection < scroll then scroll = scroll - 1 end
857 -- Send action if necessary.
858 if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
859 elseif selectChangeAction then selectChangeAction(selection) end
860 -- Redraw screen.
861 drawEntries()
862 elseif key > 0 and selection < #entries then
863 -- Move selection down.
864 selection = selection + 1
865 if selection > scroll + height - 1 then scroll = scroll + 1 end
866 -- Send action if necessary.
867 if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection)
868 elseif selectChangeAction then selectChangeAction(selection) end
869 -- Redraw screen.
870 drawEntries()
871 end
872 end
873 end
874 end
875 end)
876end
877
878--- Creates a text box that wraps text and can have its text modified later.
879---@param win window The parent window of the text box
880---@param x number The X position of the box
881---@param y number The Y position of the box
882---@param width number The width of the box
883---@param height number The height of the box
884---@param text string The initial text to draw
885---@param fgColor color|nil The color of the text (defaults to white)
886---@param bgColor color|nil The color of the background (defaults to black)
887---@return function redraw A function to redraw the window with new contents
888function PrimeUI.textBox(win, x, y, width, height, text, fgColor, bgColor)
889 expect(1, win, "table")
890 expect(2, x, "number")
891 expect(3, y, "number")
892 expect(4, width, "number")
893 expect(5, height, "number")
894 expect(6, text, "string")
895 fgColor = expect(7, fgColor, "number", "nil") or colors.white
896 bgColor = expect(8, bgColor, "number", "nil") or colors.black
897 -- Create the box window.
898 local box = window.create(win, x, y, width, height)
899 -- Override box.getSize to make print not scroll.
900 function box.getSize()
901 return width, math.huge
902 end
903 -- Define a function to redraw with.
904 local function redraw(_text)
905 expect(1, _text, "string")
906 -- Set window parameters.
907 box.setBackgroundColor(bgColor)
908 box.setTextColor(fgColor)
909 box.clear()
910 box.setCursorPos(1, 1)
911 -- Redirect and draw with `print`.
912 local old = term.redirect(box)
913 print(_text)
914 term.redirect(old)
915 end
916 redraw(text)
917 return redraw
918end
919
920--- Creates a clickable, toggleable button on screen with text.
921---@param win window The window to draw on
922---@param x number The X position of the button
923---@param y number The Y position of the button
924---@param textOn string The text to draw on the button when on
925---@param textOff string The text to draw on the button when off (must be the same length as textOn)
926---@param action function|string A function to call when clicked, or a string to send with a `run` event
927---@param fgColor color|nil The color of the button text (defaults to white)
928---@param bgColor color|nil The color of the button (defaults to light gray)
929---@param clickedColor color|nil The color of the button when clicked (defaults to gray)
930---@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)
931function PrimeUI.toggleButton(win, x, y, textOn, textOff, action, fgColor, bgColor, clickedColor, periphName)
932 expect(1, win, "table")
933 expect(1, win, "table")
934 expect(2, x, "number")
935 expect(3, y, "number")
936 expect(4, textOn, "string")
937 expect(5, textOff, "string")
938 if #textOn ~= #textOff then error("On and off text must be the same length", 2) end
939 expect(6, action, "function", "string")
940 fgColor = expect(7, fgColor, "number", "nil") or colors.white
941 bgColor = expect(8, bgColor, "number", "nil") or colors.gray
942 clickedColor = expect(9, clickedColor, "number", "nil") or colors.lightGray
943 periphName = expect(10, periphName, "string", "nil")
944 -- Draw the initial button.
945 win.setCursorPos(x, y)
946 win.setBackgroundColor(bgColor)
947 win.setTextColor(fgColor)
948 win.write(" " .. textOff .. " ")
949 local state = false
950 -- Get the screen position and add a click handler.
951 PrimeUI.addTask(function()
952 local screenX, screenY = PrimeUI.getWindowPos(win, x, y)
953 local buttonDown = false
954 while true do
955 local event, button, clickX, clickY = os.pullEvent()
956 if event == "mouse_click" and periphName == nil and button == 1 and clickX >= screenX and clickX < screenX + #textOn + 2 and clickY == screenY then
957 -- Initiate a click action (but don't trigger until mouse up).
958 buttonDown = true
959 -- Redraw the button with the clicked background color.
960 win.setCursorPos(x, y)
961 win.setBackgroundColor(clickedColor)
962 win.setTextColor(fgColor)
963 win.write(" " .. (state and textOn or textOff) .. " ")
964 elseif (event == "monitor_touch" and periphName == button and clickX >= screenX and clickX < screenX + #textOn + 2 and clickY == screenY)
965 or (event == "mouse_up" and button == 1 and buttonDown) then
966 -- Finish a click event.
967 state = not state
968 if clickX >= screenX and clickX < screenX + #textOn + 2 and clickY == screenY then
969 -- Trigger the action.
970 if type(action) == "string" then
971 PrimeUI.resolve("toggleButton", action, state)
972 else
973 action(state)
974 end
975 end
976 -- Redraw the original button state.
977 win.setCursorPos(x, y)
978 win.setBackgroundColor(bgColor)
979 win.setTextColor(fgColor)
980 win.write(" " .. (state and textOn or textOff) .. " ")
981 end
982 end
983 end)
984end
985
986--- Draws a vertical line at a position with the specified height.
987---@param win window The window to draw on
988---@param x number The X position of the line
989---@param y number The Y position of the top of the line
990---@param height number The height of the line
991---@param right boolean|nil Whether to align the line to the right instead of the left (defaults to false)
992---@param fgColor color|nil The color of the line (defaults to white)
993---@param bgColor color|nil The color of the background (defaults to black)
994function PrimeUI.verticalLine(win, x, y, height, right, fgColor, bgColor)
995 expect(1, win, "table")
996 expect(2, x, "number")
997 expect(3, y, "number")
998 expect(4, height, "number")
999 right = expect(5, right, "boolean", "nil") or false
1000 fgColor = expect(6, fgColor, "number", "nil") or colors.white
1001 bgColor = expect(7, bgColor, "number", "nil") or colors.black
1002 -- Use drawing characters to draw a thin line.
1003 win.setTextColor(right and bgColor or fgColor)
1004 win.setBackgroundColor(right and fgColor or bgColor)
1005 for j = 1, height do
1006 win.setCursorPos(x, y + j - 1)
1007 win.write("\x95")
1008 end
1009end
1010-- local ui = require "primeui"
1011ui = PrimeUI
1012
1013local function box(terminal, x, y, width, height, color, cornerStyle)
1014 cornerStyle = cornerStyle or "square"
1015 terminal.setBackgroundColor(color)
1016
1017 if cornerStyle == "square" then
1018 for i = y, y + height - 1 do
1019 terminal.setCursorPos(x, i)
1020 terminal.write(string.rep(" ", width))
1021 end
1022 elseif cornerStyle == "round" then
1023 terminal.setCursorPos(x + 1, y)
1024 terminal.write(string.rep(" ", width - 2))
1025
1026 for i = y + 1, y + height - 2 do
1027 terminal.setCursorPos(x, i)
1028 terminal.write(string.rep(" ", width))
1029 end
1030
1031 terminal.setCursorPos(x + 1, y + height - 1)
1032 terminal.write(string.rep(" ", width - 2))
1033 end
1034end
1035ui.box = box
1036
1037local api_base_url = "https://ipod-2to6magyna-uc.a.run.app/"
1038
1039local width, height = term.getSize()
1040
1041local tab = 1
1042local waiting_for_input = false
1043local last_search = nil
1044local last_search_url = nil
1045local search_results = nil
1046local search_error = false
1047local in_fullscreen = 0
1048local clicked_result = nil
1049
1050local playing = false
1051local queue = {}
1052local now_playing = nil
1053local looping = false
1054
1055local playing_id = nil
1056local last_download_url = nil
1057local playing_status = 0
1058
1059local player_handle = nil
1060local start = nil
1061local pcm = nil
1062local size = nil
1063local decoder = nil
1064local needs_next_chunk = 0
1065local buffer
1066
1067local speakers = { peripheral.find("speaker") }
1068
1069if #speakers == 0 then
1070 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)
1071end
1072
1073
1074local speaker = speakers[1]
1075
1076os.startTimer(1)
1077
1078local function playSong(song)
1079 now_playing = song
1080 playing = true
1081 playing_id = nil
1082end
1083
1084local function stopPlayback()
1085 playing = false
1086 speaker.stop()
1087 playing_id = nil
1088end
1089
1090local function togglePlayPause()
1091 if playing then
1092 stopPlayback()
1093 else
1094 if now_playing or #queue > 0 then
1095 playSong(now_playing or queue[1])
1096 end
1097 end
1098end
1099
1100local function skipSong()
1101 -- Skip to next song in queue
1102 if #queue > 0 then
1103 now_playing = queue[1]
1104 table.remove(queue, 1)
1105 playing_id = nil
1106 else
1107 now_playing = nil
1108 playing = false
1109 end
1110end
1111
1112local function toggleLoop()
1113 looping = not looping
1114end
1115
1116-- Queue management functions
1117local function addToQueue(song, position)
1118 if position then
1119 table.insert(queue, position, song)
1120 else
1121 table.insert(queue, song)
1122 end
1123end
1124
1125local function removeFromQueue(position)
1126 if position and position <= #queue then
1127 table.remove(queue, position)
1128 end
1129end
1130
1131local function clearQueue()
1132 queue = {}
1133end
1134
1135-- Search functions
1136local function searchMusic(query)
1137 last_search = query
1138 last_search_url = api_base_url .. "?search=" .. textutils.urlEncode(query)
1139 http.request(last_search_url)
1140 search_results = nil
1141 search_error = false
1142end
1143
1144local function handleAudioStream(response)
1145 player_handle = response
1146 start = response.read(4)
1147 size = 16 * 1024 - 4
1148 playing_status = 1
1149 decoder = require "cc.audio.dfpwm".make_decoder()
1150end
1151
1152local original_palette = {}
1153local function initCustomPallete()
1154 for i=1, 16 do
1155 original_palette[i] = term.getPaletteColor(i)
1156 end
1157
1158 term.setPaletteColor(colors.green, 0x1ED760)
1159 term.setPaletteColor(colors.lightGray, 0xb3b3b3)
1160 term.setPaletteColor(colors.gray, 0x212121)
1161 term.setPaletteColor(colors.purple, 0x457e59)
1162 term.setPaletteColor(colors.magenta, 0x62d089)
1163 term.setPaletteColor(colors.brown, 0x2e2e2e)
1164end
1165
1166local function truncateString(str, limit)
1167 if not str then return "" end
1168
1169 if #str <= limit then
1170 return str
1171 end
1172
1173 return string.sub(str, 1, limit - 3) .. "..."
1174end
1175
1176
1177ui.page = 1
1178
1179local function redrawScreen()
1180 ui.clear()
1181 ui.borderBox(term.current(), 3, 2, width-4, 1, colors.gray)
1182
1183 local isSmallScreen = width <= 30
1184
1185
1186 if now_playing then
1187 if playing then
1188 if isSmallScreen then
1189 ui.button(term.current(), 4, 2, "S", "pause", colors.white, colors.red, colors.orange)
1190 else
1191 ui.button(term.current(), 4, 2, "Stop", "pause", colors.white, colors.red, colors.orange)
1192 end
1193 else
1194 ui.button(term.current(), 4, 2, "\16", "resume", colors.white, colors.green, colors.lightGray)
1195 ui.button(term.current(), 8, 2, "R", "clear", colors.white, colors.red, colors.orange)
1196 end
1197 if not isSmallScreen then
1198 ui.label(term.current(), 12, 2, truncateString(now_playing.name, 20), colors.white)
1199 ui.label(term.current(), 12+string.len(truncateString(now_playing.name, 20))+1, 2, "| "..truncateString(now_playing.artist, 14), colors.lightGray)
1200 else
1201 ui.label(term.current(), 8, 2, truncateString(now_playing.name, 16), colors.white)
1202 end
1203 else
1204 ui.label(term.current(), 4, 2, "Musiclo", colors.green)
1205 if not isSmallScreen then
1206 ui.label(term.current(), 4+string.len("Musiclo")+2, 2, "| CC:T music player made easy", colors.lightGray)
1207 else
1208 ui.label(term.current(), 4+string.len("Musiclo")+1, 2, "| CC:T player", colors.lightGray)
1209 end
1210 end
1211
1212
1213 local titleTruncateLimit = 41
1214 local artistTruncateLimit = 26
1215
1216 if isSmallScreen then
1217 titleTruncateLimit = 19
1218 artistTruncateLimit = 15
1219 end
1220
1221
1222
1223 if ui.page == 1 then
1224 ui.borderBox(term.current(), 3, 5, width-4, height-6, colors.gray)
1225
1226 ui.button(term.current(), width-9, 4, "Search", "page.2", colors.white, colors.magenta, colors.purple)
1227 ui.keyAction(keys.enter, "page.2")
1228
1229 ui.label(term.current(), 4, 4, "Queue", colors.white)
1230
1231 if now_playing and playing then
1232 ui.keyAction(keys.space, "pause")
1233 elseif now_playing then
1234 ui.keyAction(keys.space, "resume")
1235 end
1236
1237 if looping then
1238 ui.button(term.current(), 4, height-1, "Loop", "loop", colors.white, colors.magenta, colors.purple)
1239 else
1240 ui.button(term.current(), 4, height-1, "Loop", "loop", colors.white, colors.gray, colors.lightGray)
1241 end
1242
1243 if #queue > 0 then
1244 ui.button(term.current(), 11, height-1, "Skip", "skip", colors.white, colors.gray, colors.lightGray)
1245 if isSmallScreen then
1246 ui.button(term.current(), 18, height-1, "Clr", "clear.q", colors.white, colors.red, colors.orange)
1247 else
1248 ui.button(term.current(), 18, height-1, "Clear queue", "clear.q", colors.white, colors.red, colors.orange)
1249 end
1250 end
1251 ui.label(term.current(), 4, 6, "Now playing", colors.white)
1252
1253 local scroller = ui.scrollBox(term.current(), 3, 5, width-4, height-6, 9000, true, true)
1254
1255 y = 2
1256 if #queue > 0 then
1257 for i, song in ipairs(queue) do
1258 ui.box(scroller, 1, y, width-5, 5, colors.brown)
1259 ui.label(scroller, 2, y+1, truncateString(song.name, titleTruncateLimit), colors.white, colors.brown)
1260 ui.label(scroller, 2, y+2, truncateString(song.artist, artistTruncateLimit), colors.lightGray, colors.brown)
1261 if isSmallScreen then y = y + 1 end
1262 ui.button(scroller, width-20, y+2, "Play", "play."..i, colors.white, colors.magenta, colors.purple)
1263 local songInQueue = false
1264 for _, queuedSong in ipairs(queue) do
1265 if queuedSong.id == song.id then
1266 songInQueue = true
1267 break
1268 end
1269 end
1270 if songInQueue then
1271 ui.button(scroller, width-13, y+2, "Remove", "rem."..i, colors.white, colors.red, colors.orange)
1272 else
1273 ui.button(scroller, width-13, y+2, "Add", "add."..i, colors.white, colors.gray, colors.lightGray)
1274 end
1275 y = y + 6
1276 end
1277 else
1278 ui.centerLabel(scroller, 1, 5,width-4, "No songs in queue", colors.lightGray)
1279 ui.button(scroller, ((width-4-3)/2-(string.len("Add song")/2))+1, 7 ,"Add song", "page.2", colors.white, colors.gray, colors.lightGray)
1280 end
1281 elseif ui.page == 2 then
1282 ui.borderBox(term.current(), 3, 5, width-4, height-6, colors.gray)
1283
1284 ui.button(term.current(), width-10, 4, "Go back","page.1", colors.white, colors.gray, colors.lightGray)
1285 ui.label(term.current(), 4, 4, "Search", colors.white)
1286
1287 ui.label(term.current(), 4, 6, "Search on Youtube...", colors.lightGray)
1288
1289 ui.horizontalLine(term.current(), 3, 8, width-4, colors.gray)
1290
1291 local scroller = ui.scrollBox(term.current(), 3, 9, width-4, height-10, 9000, true, true)
1292
1293 y = 2
1294 if search_results then
1295 for i, song in ipairs(search_results) do
1296 ui.box(scroller, 1, y, width-6, 5, colors.brown)
1297 ui.label(scroller, 2, y+1, truncateString(song.name, titleTruncateLimit), colors.white, colors.brown)
1298 ui.label(scroller, 2, y+2, truncateString(song.artist, artistTruncateLimit), colors.lightGray, colors.brown)
1299 if isSmallScreen then y = y + 1 end
1300 ui.button(scroller, width-21, y+2, "Play", "play."..i, colors.white, colors.magenta, colors.purple)
1301 local songInQueue = false
1302 for _, queuedSong in ipairs(queue) do
1303 if queuedSong.id == song.id then
1304 songInQueue = true
1305 break
1306 end
1307 end
1308 if songInQueue then
1309 ui.button(scroller, width-14, y+2, "Remove", "rem."..i, colors.white, colors.red, colors.orange)
1310 else
1311 ui.button(scroller, width-14, y+2, "Add", "add."..i, colors.white, colors.gray, colors.lightGray)
1312 end
1313 y = y + 6
1314 end
1315 end
1316
1317 ui.inputBox(term.current(), 4, 7, width-7, "search", colors.white, colors.gray)
1318 end
1319
1320
1321 local object, callback, text = ui.run()
1322 term.clear()
1323 term.setCursorPos(1, 1)
1324 if object == "button" then
1325 if callback == "page.2" then
1326 ui.page = 2
1327 elseif callback == "page.1" then
1328 ui.page = 1
1329 elseif callback:sub(1, 4) == "play" then
1330 local index = tonumber(callback:sub(6))
1331 if index and search_results[index] then
1332 playSong(search_results[index])
1333 ui.page = 1
1334 end
1335 elseif callback:sub(1, 3) == "add" then
1336 local index = tonumber(callback:sub(5))
1337 if index and search_results[index] then
1338 addToQueue(search_results[index])
1339 end
1340 elseif callback:sub(1, 4) == "rem" then
1341 local index = tonumber(callback:sub(6))
1342 if index and search_results[index] then
1343 removeFromQueue(index)
1344 end
1345 elseif callback == "pause" then
1346 stopPlayback()
1347 elseif callback == "resume" then
1348 playing = true
1349 elseif callback == "loop" then
1350 toggleLoop()
1351 elseif callback == "skip" then
1352 skipSong()
1353 elseif callback == "clear.q" then
1354 clearQueue()
1355 elseif callback == "clear" then
1356 playing = false
1357 now_playing = nil
1358 playing_id = nil
1359 end
1360 elseif object == "keyAction" then
1361 if callback == "page.2" then
1362 ui.page = 2
1363 elseif callback == "page.1" then
1364 ui.page = 1
1365 end
1366 elseif object == "inputBox" and callback == "search" then
1367 if text ~= "" then
1368 searchMusic(text)
1369 term.clear()
1370 local sx, sy = term.getSize()
1371 term.setTextColor(colors.lightGray)
1372 term.setCursorPos(sx/2 - #"Fetching..."/2, sy/2)
1373 term.write("Fetching...")
1374 ui.searchDone = false
1375 repeat
1376 sleep(0.1)
1377 until ui.searchDone == true
1378 ui.searchDone = false
1379 end
1380 elseif object == "rerender" then
1381 print("rerender")
1382 else
1383 term.clear()
1384 term.setCursorPos(1, 1)
1385 error("["..(object or "No object").."] "..(callback or "No callback").." "..(text or "No text").." not handled! Exiting",0)
1386 end
1387end
1388
1389local function mainLoop()
1390
1391 while true do
1392 -- AUDIO
1393 sleep(0.1)
1394 if playing and now_playing then
1395 if playing_id ~= now_playing.id then
1396 playing_id = now_playing.id
1397 last_download_url = api_base_url .. "?v=2&id=" .. textutils.urlEncode(playing_id)
1398 playing_status = 0
1399 needs_next_chunk = 1
1400
1401 http.request({url = last_download_url, binary = true})
1402 is_loading = true
1403
1404 end
1405 if playing_status == 1 and needs_next_chunk == 3 then
1406 needs_next_chunk = 1
1407 for _, speaker in ipairs(speakers) do
1408 while not speaker.playAudio(buffer) do
1409 needs_next_chunk = 2
1410 break
1411 end
1412 end
1413 end
1414 if playing_status == 1 and needs_next_chunk == 1 then
1415
1416 while true do
1417 local chunk = player_handle.read(size)
1418 if not chunk then
1419 if looping then
1420 playing_id = nil
1421 else
1422 if #queue > 0 then
1423 now_playing = queue[1]
1424 table.remove(queue, 1)
1425 playing_id = nil
1426 else
1427 now_playing = nil
1428 playing = false
1429 playing_id = nil
1430 is_loading = false
1431 is_error = false
1432 end
1433 end
1434
1435
1436 player_handle.close()
1437 needs_next_chunk = 0
1438 break
1439 else
1440 if start then
1441 chunk, start = start .. chunk, nil
1442 size = size + 4
1443 end
1444
1445 buffer = decoder(chunk)
1446 for _, speaker in ipairs(speakers) do
1447 while not speaker.playAudio(buffer) do
1448 needs_next_chunk = 2
1449 break
1450 end
1451 end
1452 if needs_next_chunk == 2 then
1453 break
1454 end
1455 end
1456 end
1457
1458 end
1459 end
1460 end
1461end
1462initCustomPallete()
1463parallel.waitForAny(mainLoop, function()
1464 while true do
1465 local event, param1, param2, param3, param4 = os.pullEvent()
1466
1467 if event == "timer" then
1468 os.startTimer(1)
1469 end
1470
1471 if event == "speaker_audio_empty" then
1472 if needs_next_chunk == 2 then
1473 needs_next_chunk = 3
1474 end
1475 end
1476
1477 if event == "http_success" then
1478 local url = param1
1479 local handle = param2
1480 if url == last_search_url then
1481 search_results = textutils.unserialiseJSON(handle.readAll())
1482 table.remove(search_results, 1)
1483 ui.searchDone = true
1484 -- term.clear()
1485 -- term.setCursorPos(1, 1)
1486 -- error(textutils.serialise(search_results),0)
1487 end
1488 if url == last_download_url then
1489 player_handle = handle
1490 start = handle.read(4)
1491 size = 16 * 1024 - 4
1492 if start == "RIFF" then
1493 error("WAV not supported!")
1494 end
1495 playing_status = 1
1496 decoder = require "cc.audio.dfpwm".make_decoder()
1497 end
1498 end
1499
1500 if event == "http_failure" then
1501 local url = param1
1502
1503 if url == last_search_url then
1504 search_error = true
1505 ui.searchDone = true
1506 end
1507 if url == last_download_url then
1508 if #queue > 0 then
1509 now_playing = queue[1]
1510 table.remove(queue, 1)
1511 playing_id = nil
1512 else
1513 now_playing = nil
1514 playing = false
1515 playing_id = nil
1516 end
1517 end
1518 end
1519 end
1520end, function() while true do redrawScreen() end end)
1521