XEXModz

ytplayer-v2.1

Apr 26th, 2026 (edited)
43
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 35.54 KB | None | 0 0
  1. --[[
  2. ATM-Tube v2 — Tabbed YouTube player for CC:Tweaked
  3. ----------------------------------------------------
  4. Streams via self-hosted YouCube (ws://192.168.1.22:5000), renders 32vid frames
  5. to monitor, plays DFPWM audio across ALL connected speakers in parallel.
  6.  
  7. Tabs (switch with Tab key, or number keys 1-4 outside the Search tab):
  8. [1] Search — type a query/URL, Enter to play (prefix 'q ' to queue)
  9. [2] Now Playing — title, time, controls (Stop/Skip/Loop, +/- volume)
  10. [3] Queue — upcoming videos, manage with arrows + J/K/D/C
  11. [4] History — recently played, R to replay, A to add to queue
  12.  
  13. Architecture:
  14. parallel.waitForAny(
  15. eventLoop, -- routes keys/touches to active tab; chars feed search buf
  16. playerLoop, -- pulls audio + video from YouCube, drives speakers/monitor
  17. footerTicker -- redraws footer every second so elapsed time updates
  18. )
  19.  
  20. Hardware: same as v1 (advanced computer + monitor + 1+ speakers).
  21. ]]--
  22.  
  23. ------------------------------------------------------------
  24. -- 0. Environment checks
  25. ------------------------------------------------------------
  26. if not http or not http.websocket then
  27. error("HTTP API disabled. Enable in CC:Tweaked config.")
  28. end
  29. local has_dfpwm, dfpwm = pcall(require, "cc.audio.dfpwm")
  30. if not has_dfpwm then
  31. error("cc.audio.dfpwm unavailable. Need CC:Tweaked >= 1.100.")
  32. end
  33.  
  34. ------------------------------------------------------------
  35. -- 1. Configuration
  36. ------------------------------------------------------------
  37. local SERVER = "ws://192.168.1.15:5000" -- desktop YouCube (start with `yc-on`)
  38. local CONNECT_TIMEOUT = 5
  39. local DEFAULT_VOLUME = 1.0
  40. local HISTORY_PATH = "atmtube_history.txt"
  41. local HISTORY_MAX = 25
  42. local STRING_PACK_URL = "https://gist.githubusercontent.com/MCJack123/d5973e4d8b7e46991c5f99ac4b076aec/raw/string_pack.lua"
  43. local STRING_PACK_PATH = "lib/string_pack.lua"
  44.  
  45. ------------------------------------------------------------
  46. -- 2. Peripherals (same discovery as v1)
  47. ------------------------------------------------------------
  48. local function findAllSpeakers()
  49. local list = {}
  50. for _, name in ipairs(peripheral.getNames()) do
  51. if peripheral.getType(name) == "speaker" then
  52. table.insert(list, peripheral.wrap(name))
  53. end
  54. end
  55. return list
  56. end
  57.  
  58. local function findBestMonitor()
  59. local best, bestArea = nil, 0
  60. for _, name in ipairs(peripheral.getNames()) do
  61. if peripheral.getType(name) == "monitor" then
  62. local m = peripheral.wrap(name)
  63. m.setTextScale(0.5)
  64. local w, h = m.getSize()
  65. if w * h > bestArea then best, bestArea = m, w * h end
  66. end
  67. end
  68. return best
  69. end
  70.  
  71. local speakers = findAllSpeakers()
  72. local monitor = findBestMonitor()
  73. if #speakers == 0 then printError("No speakers found."); return end
  74. if not monitor then printError("No monitor found."); return end
  75. monitor.setTextScale(0.5)
  76.  
  77. ------------------------------------------------------------
  78. -- 3. string.unpack polyfill bootstrap (same as v1)
  79. ------------------------------------------------------------
  80. local string_unpack
  81. if string.unpack then
  82. string_unpack = string.unpack
  83. else
  84. if not fs.exists(STRING_PACK_PATH) then
  85. if not fs.exists("lib") then fs.makeDir("lib") end
  86. local resp, err = http.get(STRING_PACK_URL)
  87. if not resp then error("Cannot fetch string_pack: " .. tostring(err)) end
  88. local f = fs.open(STRING_PACK_PATH, "w")
  89. f.write(resp.readAll()); f.close(); resp.close()
  90. end
  91. local sp = dofile(shell.resolve(STRING_PACK_PATH))
  92. if type(sp) == "table" and sp.unpack then
  93. string_unpack = sp.unpack
  94. elseif string.unpack then
  95. string_unpack = string.unpack
  96. else
  97. error("string_pack.lua loaded but no .unpack")
  98. end
  99. end
  100.  
  101. ------------------------------------------------------------
  102. -- 4. Base64 + 32vid decoder (verbatim from v1)
  103. ------------------------------------------------------------
  104. local b64str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
  105. local function base64_decode(str)
  106. local retval = ""
  107. for s in str:gmatch("....") do
  108. if s:sub(3, 4) == "==" then
  109. retval = retval .. string.char(bit32.bor(
  110. bit32.lshift(b64str:find(s:sub(1, 1)) - 1, 2),
  111. bit32.rshift(b64str:find(s:sub(2, 2)) - 1, 4)))
  112. elseif s:sub(4, 4) == "=" then
  113. local n = (b64str:find(s:sub(1, 1)) - 1) * 4096
  114. + (b64str:find(s:sub(2, 2)) - 1) * 64
  115. + (b64str:find(s:sub(3, 3)) - 1)
  116. retval = retval .. string.char(bit32.extract(n, 10, 8))
  117. .. string.char(bit32.extract(n, 2, 8))
  118. else
  119. local n = (b64str:find(s:sub(1, 1)) - 1) * 262144
  120. + (b64str:find(s:sub(2, 2)) - 1) * 4096
  121. + (b64str:find(s:sub(3, 3)) - 1) * 64
  122. + (b64str:find(s:sub(4, 4)) - 1)
  123. retval = retval .. string.char(bit32.extract(n, 16, 8))
  124. .. string.char(bit32.extract(n, 8, 8))
  125. .. string.char(bit32.extract(n, 0, 8))
  126. end
  127. end
  128. return retval
  129. end
  130.  
  131. local function decodeFrame(frame, target)
  132. local mode = frame:match("^!CP([CD])")
  133. if not mode then return nil end
  134. local b64data
  135. if mode == "C" then
  136. local len = tonumber(frame:sub(5, 8), 16)
  137. if not len then return nil end
  138. b64data = frame:sub(9, len + 8)
  139. else
  140. local len = tonumber(frame:sub(5, 16), 16)
  141. if not len then return nil end
  142. b64data = frame:sub(17, len + 16)
  143. end
  144. local data = base64_decode(b64data)
  145. if data:sub(1, 4) ~= "\0\0\0\0" or data:sub(9, 16) ~= "\0\0\0\0\0\0\0\0" then
  146. return nil
  147. end
  148. local width, height = string_unpack("HH", data, 5)
  149. local c, n, pos = string_unpack("c1B", data, 17)
  150. local text = {}
  151. for y = 1, height do
  152. text[y] = ""
  153. for _ = 1, width do
  154. text[y] = text[y] .. c
  155. n = n - 1
  156. if n == 0 then c, n, pos = string_unpack("c1B", data, pos) end
  157. end
  158. end
  159. c = c:byte()
  160. for y = 1, height do
  161. local fg, bg = "", ""
  162. for _ = 1, width do
  163. fg = fg .. ("%x"):format(bit32.band(c, 0x0F))
  164. bg = bg .. ("%x"):format(bit32.rshift(c, 4))
  165. n = n - 1
  166. if n == 0 then c, n, pos = string_unpack("BB", data, pos) end
  167. end
  168. target.setCursorPos(1, y)
  169. target.blit(text[y], fg, bg)
  170. end
  171. pos = pos - 2
  172. for i = 0, 15 do
  173. local r, g, b
  174. r, g, b, pos = string_unpack("BBB", data, pos)
  175. target.setPaletteColor(2 ^ i, r / 255, g / 255, b / 255)
  176. end
  177. return true
  178. end
  179.  
  180. local function reset_palette(t)
  181. for i = 0, 15 do
  182. t.setPaletteColor(2 ^ i, term.nativePaletteColor(2 ^ i))
  183. end
  184. t.setBackgroundColor(colors.black)
  185. t.setTextColor(colors.white)
  186. t.clear()
  187. t.setCursorPos(1, 1)
  188. end
  189.  
  190. ------------------------------------------------------------
  191. -- 5. WebSocket helpers
  192. ------------------------------------------------------------
  193. local function websocket_with_timeout(url, timeout)
  194. if not http.websocketAsync then return http.websocket(url) end
  195. local ws, err = http.websocketAsync(url)
  196. if not ws then return false, err end
  197. local timerId = os.startTimer(timeout)
  198. while true do
  199. local ev, p1, p2 = os.pullEvent()
  200. if ev == "websocket_success" and p1 == url then
  201. os.cancelTimer(timerId); return p2
  202. elseif ev == "websocket_failure" and p1 == url then
  203. os.cancelTimer(timerId); return false, p2
  204. elseif ev == "timer" and p1 == timerId then
  205. return false, "timeout"
  206. end
  207. end
  208. end
  209.  
  210. local function send(ws, tbl) ws.send(textutils.serialiseJSON(tbl)) end
  211.  
  212. -- Receive with a timeout. Returns nil, "timeout" if no message arrives in
  213. -- `timeout` seconds. ws.receive(timeout) is documented in CC:Tweaked.
  214. local RECV_TIMEOUT = 30 -- generous for first-run transcodes
  215.  
  216. local function recv(ws, filter)
  217. while true do
  218. local raw, err = ws.receive(RECV_TIMEOUT)
  219. if not raw then
  220. return nil, err == nil and "timeout" or err
  221. end
  222. local msg = textutils.unserialiseJSON(raw)
  223. if not msg then return nil, "malformed JSON" end
  224. if msg.action == "error" then return nil, msg.message end
  225. if not filter or msg.action == filter then return msg end
  226. -- Status / progress messages fall through; loop and re-receive
  227. end
  228. end
  229.  
  230. ------------------------------------------------------------
  231. -- 6. State
  232. ------------------------------------------------------------
  233. local TW, TH = term.getSize()
  234. local MW, MH = monitor.getSize()
  235.  
  236. local state = {
  237. activeTab = 1,
  238. volume = DEFAULT_VOLUME,
  239. queue = {}, -- list of {query=..., title=...}
  240. history = {}, -- list of {query=..., title=..., when=...}
  241. nowPlaying = nil, -- {title=..., id=..., started=epoch_ms, duration=nil}
  242. queueCursor = 1, -- highlighted row in Queue tab
  243. historyCursor = 1, -- highlighted row in History tab
  244. loop = false, -- if true, finished video re-queues itself
  245. stopFlag = false, -- stop CURRENT playback
  246. quitFlag = false, -- exit whole app
  247. statusLine = "Ready.",
  248. searchBuffer = "", -- live search-input text (Tab 1 only)
  249. }
  250.  
  251. ------------------------------------------------------------
  252. -- 7. History persistence
  253. ------------------------------------------------------------
  254. local function loadHistory()
  255. if not fs.exists(HISTORY_PATH) then return end
  256. local f = fs.open(HISTORY_PATH, "r")
  257. local raw = f.readAll(); f.close()
  258. local ok, list = pcall(textutils.unserialise, raw)
  259. if ok and type(list) == "table" then state.history = list end
  260. end
  261.  
  262. local function saveHistory()
  263. local f = fs.open(HISTORY_PATH, "w")
  264. f.write(textutils.serialise(state.history)); f.close()
  265. end
  266.  
  267. local function pushHistory(entry)
  268. -- de-dupe (move to top if already present)
  269. for i = #state.history, 1, -1 do
  270. if state.history[i].query == entry.query then
  271. table.remove(state.history, i)
  272. end
  273. end
  274. table.insert(state.history, 1, entry)
  275. while #state.history > HISTORY_MAX do table.remove(state.history) end
  276. saveHistory()
  277. end
  278.  
  279. loadHistory()
  280.  
  281. ------------------------------------------------------------
  282. -- 8. Tab windows
  283. -- One window per tab, all stacked at same coords (header line 1, body lines
  284. -- 2..TH-1, footer line TH). We toggle .setVisible() to switch tabs.
  285. ------------------------------------------------------------
  286. local headerWin = window.create(term.current(), 1, 1, TW, 1, true)
  287. local bodyWins = {
  288. window.create(term.current(), 1, 2, TW, TH - 2, true),
  289. window.create(term.current(), 1, 2, TW, TH - 2, false),
  290. window.create(term.current(), 1, 2, TW, TH - 2, false),
  291. window.create(term.current(), 1, 2, TW, TH - 2, false),
  292. }
  293. local footerWin = window.create(term.current(), 1, TH, TW, 1, true)
  294.  
  295. local TAB_NAMES = { "Search", "Playing", "Queue", "History" }
  296.  
  297. -- Forward declarations: closures inside drawHeader/drawNowPlayingTab/etc.
  298. -- reference redraw() before its actual definition further down. Lua resolves
  299. -- upvalues at call-time, not declaration-time, so as long as the local slot
  300. -- exists at closure-creation time we're fine.
  301. local redraw
  302.  
  303. -- Hitbox map: { {x1, x2, y, action_fn}, ... } - rebuilt on every redraw.
  304. -- Used by mouse_click + monitor_touch event handlers.
  305. local hitboxes = {}
  306.  
  307. local function addHit(x1, x2, y, fn)
  308. table.insert(hitboxes, { x1 = x1, x2 = x2, y = y, fn = fn })
  309. end
  310.  
  311. local function clearHits() hitboxes = {} end
  312.  
  313. local function dispatchHit(x, y)
  314. for _, h in ipairs(hitboxes) do
  315. if y == h.y and x >= h.x1 and x <= h.x2 then
  316. h.fn()
  317. return true
  318. end
  319. end
  320. return false
  321. end
  322.  
  323. ------------------------------------------------------------
  324. -- 9. Drawing helpers
  325. ------------------------------------------------------------
  326. local function switchTab(n)
  327. state.activeTab = n
  328. -- redraw is called by caller
  329. end
  330.  
  331. local function drawHeader()
  332. headerWin.setBackgroundColor(colors.black)
  333. headerWin.setTextColor(colors.white)
  334. headerWin.clear()
  335. headerWin.setCursorPos(1, 1)
  336. local x = 1
  337. -- First pass: measure how wide tabs would be, fall back to compact form if needed
  338. local total = 0
  339. for i, name in ipairs(TAB_NAMES) do
  340. total = total + #(" [" .. i .. "] " .. name .. " ")
  341. end
  342. local quitLabel = " [Q] Quit "
  343. -- If tabs + quit label don't fit, shrink labels to just the number
  344. local compact = total + #quitLabel > TW - 1
  345.  
  346. for i, name in ipairs(TAB_NAMES) do
  347. local label = compact and ("[" .. i .. "]") or ("[" .. i .. "] " .. name)
  348. local cell = " " .. label .. " "
  349. if i == state.activeTab then
  350. headerWin.setBackgroundColor(colors.red)
  351. headerWin.setTextColor(colors.white)
  352. else
  353. headerWin.setBackgroundColor(colors.gray)
  354. headerWin.setTextColor(colors.lightGray)
  355. end
  356. headerWin.setCursorPos(x, 1)
  357. headerWin.write(cell)
  358. local cellEnd = x + #cell - 1
  359. -- Tab hitbox is on the global header row (line 1)
  360. local tab_i = i
  361. addHit(x, cellEnd, 1, function() switchTab(tab_i); redraw() end)
  362. x = cellEnd + 1
  363. end
  364.  
  365. -- Quit at right
  366. headerWin.setBackgroundColor(colors.gray)
  367. headerWin.setTextColor(colors.lightGray)
  368. local qx = TW - #quitLabel + 1
  369. headerWin.setCursorPos(qx, 1)
  370. headerWin.write(quitLabel)
  371. addHit(qx, TW, 1, function() state.quitFlag = true; state.stopFlag = true end)
  372. end
  373.  
  374. local function drawFooter()
  375. footerWin.setBackgroundColor(colors.black)
  376. footerWin.setTextColor(colors.lightGray)
  377. footerWin.clear()
  378. footerWin.setCursorPos(1, 1)
  379. local left
  380. if state.nowPlaying then
  381. local elapsed = math.floor((os.epoch("utc") - state.nowPlaying.started) / 1000)
  382. local mm = math.floor(elapsed / 60)
  383. local ss = elapsed % 60
  384. left = ("\7 %s %d:%02d"):format(state.nowPlaying.title:sub(1, 30), mm, ss)
  385. else
  386. left = state.statusLine
  387. end
  388. local right = ("V%.2f %dsp %dx%d"):format(state.volume, #speakers, MW, MH)
  389. footerWin.write(left)
  390. if #left + #right + 2 <= TW then
  391. footerWin.setCursorPos(TW - #right + 1, 1)
  392. footerWin.write(right)
  393. end
  394. end
  395.  
  396. local function bodyOf(tab) return bodyWins[tab] end
  397.  
  398. local function clearBody(win)
  399. win.setBackgroundColor(colors.black)
  400. win.setTextColor(colors.white)
  401. win.clear()
  402. win.setCursorPos(1, 1)
  403. end
  404.  
  405. ------------------------------------------------------------
  406. -- 10. Tab renderers
  407. ------------------------------------------------------------
  408. local function drawSearchTab()
  409. local w = bodyOf(1)
  410. clearBody(w)
  411. w.setCursorPos(2, 2); w.setTextColor(colors.white)
  412. w.write("Search YouTube (URL, video ID, or text query):")
  413. w.setCursorPos(2, 4)
  414. w.setTextColor(colors.lightGray)
  415. w.write("Type then press Enter. Backspace edits. ESC clears.")
  416. w.setCursorPos(2, 5)
  417. w.write("Prefix with 'q ' to add to queue instead of playing now.")
  418.  
  419. -- Input box
  420. w.setCursorPos(2, 8); w.setTextColor(colors.white)
  421. w.write("> ")
  422. w.setBackgroundColor(colors.gray); w.setTextColor(colors.white)
  423. local boxW = TW - 6
  424. local shown = state.searchBuffer
  425. if #shown > boxW then shown = shown:sub(#shown - boxW + 1) end
  426. w.write(shown .. string.rep(" ", math.max(0, boxW - #shown)))
  427. w.setBackgroundColor(colors.black)
  428. -- Blinking caret indicator
  429. if state.activeTab == 1 then
  430. w.setTextColor(colors.red)
  431. local caretX = 4 + math.min(#state.searchBuffer, boxW)
  432. w.setCursorPos(caretX, 8)
  433. w.write("_")
  434. end
  435.  
  436. w.setCursorPos(2, 11); w.setTextColor(colors.yellow)
  437. w.write("Status: " .. state.statusLine:sub(1, TW - 12))
  438. end
  439.  
  440. -- Button helper: draws a colored block with text, registers a hitbox.
  441. -- y is body-window-relative; we add +1 to convert to screen coords (header takes line 1).
  442. local function drawButton(w, x, y, label, bg, fg, action)
  443. w.setBackgroundColor(bg)
  444. w.setTextColor(fg or colors.white)
  445. w.setCursorPos(x, y)
  446. w.write(" " .. label .. " ")
  447. local screenY = y + 1
  448. addHit(x, x + #label + 1, screenY, action)
  449. w.setBackgroundColor(colors.black)
  450. end
  451.  
  452. local function drawNowPlayingTab()
  453. local w = bodyOf(2)
  454. clearBody(w)
  455. if not state.nowPlaying then
  456. w.setCursorPos(2, 3); w.setTextColor(colors.lightGray)
  457. w.write("Nothing playing. Use Search tab to start a video.")
  458. return
  459. end
  460. w.setCursorPos(2, 2); w.setTextColor(colors.white)
  461. w.write(state.nowPlaying.title:sub(1, TW - 4))
  462. w.setCursorPos(2, 3); w.setTextColor(colors.gray)
  463. w.write("id: " .. state.nowPlaying.id)
  464.  
  465. local elapsed = math.floor((os.epoch("utc") - state.nowPlaying.started) / 1000)
  466. w.setCursorPos(2, 5); w.setTextColor(colors.white)
  467. w.write(("Elapsed: %d:%02d"):format(math.floor(elapsed / 60), elapsed % 60))
  468.  
  469. -- Transport buttons
  470. w.setCursorPos(2, 7); w.setTextColor(colors.white)
  471. w.write("Transport:")
  472. drawButton(w, 2, 9, "STOP", colors.red, colors.white,
  473. function() state.stopFlag = true end)
  474. drawButton(w, 9, 9, "SKIP", colors.orange, colors.black,
  475. function() state.stopFlag = true end)
  476. local loopLabel = state.loop and "LOOP ON " or "LOOP OFF"
  477. drawButton(w, 16, 9, loopLabel,
  478. state.loop and colors.lime or colors.gray,
  479. colors.black,
  480. function() state.loop = not state.loop; redraw() end)
  481.  
  482. -- Volume row
  483. w.setCursorPos(2, 11); w.setTextColor(colors.white)
  484. w.write("Volume:")
  485. drawButton(w, 11, 11, "-", colors.gray, colors.white,
  486. function() state.volume = math.max(0.0, state.volume - 0.25); redraw() end)
  487. drawButton(w, 16, 11, "+", colors.gray, colors.white,
  488. function() state.volume = math.min(3.0, state.volume + 0.25); redraw() end)
  489. w.setCursorPos(21, 11)
  490. w.setTextColor(colors.lightGray)
  491. w.write(("%.2f / 3.00"):format(state.volume))
  492.  
  493. w.setCursorPos(2, 14); w.setTextColor(colors.gray)
  494. w.write(("Queue: %d History: %d"):format(#state.queue, #state.history))
  495. end
  496.  
  497. local function drawQueueTab()
  498. local w = bodyOf(3)
  499. clearBody(w)
  500. w.setCursorPos(2, 1); w.setTextColor(colors.white)
  501. w.write(("Queue (%d):"):format(#state.queue))
  502. if #state.queue == 0 then
  503. w.setCursorPos(2, 3); w.setTextColor(colors.lightGray)
  504. w.write("Empty. From Search tab, prefix queries with 'q '.")
  505. else
  506. if state.queueCursor > #state.queue then state.queueCursor = #state.queue end
  507. if state.queueCursor < 1 then state.queueCursor = 1 end
  508. local rows = math.min(#state.queue, TH - 5)
  509. for i = 1, rows do
  510. local item = state.queue[i]
  511. local bodyY = 2 + i
  512. w.setCursorPos(2, bodyY)
  513. if i == state.queueCursor then
  514. w.setBackgroundColor(colors.gray); w.setTextColor(colors.white)
  515. else
  516. w.setBackgroundColor(colors.black); w.setTextColor(colors.lightGray)
  517. end
  518. local line = (" %d. %s"):format(i, item.title or item.query)
  519. w.write(line:sub(1, TW - 2) .. string.rep(" ", math.max(0, TW - 2 - #line)))
  520. -- Click row = move to front + play
  521. local idx = i
  522. addHit(2, TW - 1, bodyY + 1, function()
  523. local it = table.remove(state.queue, idx)
  524. table.insert(state.queue, 1, it)
  525. if state.nowPlaying then state.stopFlag = true end
  526. redraw()
  527. end)
  528. end
  529. w.setBackgroundColor(colors.black)
  530. end
  531. w.setCursorPos(2, TH - 3); w.setTextColor(colors.gray)
  532. w.write("Click row to play Up/Dn arrows + J/K reorder D del C clear")
  533. end
  534.  
  535. local function drawHistoryTab()
  536. local w = bodyOf(4)
  537. clearBody(w)
  538. w.setCursorPos(2, 1); w.setTextColor(colors.white)
  539. w.write(("History (%d):"):format(#state.history))
  540. if #state.history == 0 then
  541. w.setCursorPos(2, 3); w.setTextColor(colors.lightGray)
  542. w.write("No videos played yet.")
  543. else
  544. if state.historyCursor > #state.history then state.historyCursor = #state.history end
  545. if state.historyCursor < 1 then state.historyCursor = 1 end
  546. local rows = math.min(#state.history, TH - 5)
  547. for i = 1, rows do
  548. local item = state.history[i]
  549. local bodyY = 2 + i
  550. w.setCursorPos(2, bodyY)
  551. if i == state.historyCursor then
  552. w.setBackgroundColor(colors.gray); w.setTextColor(colors.white)
  553. else
  554. w.setBackgroundColor(colors.black); w.setTextColor(colors.lightGray)
  555. end
  556. local line = (" %d. %s"):format(i, item.title or item.query)
  557. w.write(line:sub(1, TW - 2) .. string.rep(" ", math.max(0, TW - 2 - #line)))
  558. -- Click row = replay
  559. local idx = i
  560. addHit(2, TW - 1, bodyY + 1, function()
  561. local it = state.history[idx]
  562. table.insert(state.queue, 1, { query = it.query, title = it.title })
  563. if state.nowPlaying then state.stopFlag = true end
  564. redraw()
  565. end)
  566. end
  567. w.setBackgroundColor(colors.black)
  568. end
  569. w.setCursorPos(2, TH - 3); w.setTextColor(colors.gray)
  570. w.write("Click row to replay Arrows highlight A queue D forget C clear")
  571. end
  572.  
  573. local TAB_DRAWERS = { drawSearchTab, drawNowPlayingTab, drawQueueTab, drawHistoryTab }
  574.  
  575. redraw = function()
  576. clearHits()
  577. drawHeader()
  578. -- toggle window visibility
  579. for i, w in ipairs(bodyWins) do w.setVisible(i == state.activeTab) end
  580. TAB_DRAWERS[state.activeTab]()
  581. drawFooter()
  582. end
  583.  
  584. local function setStatus(s)
  585. state.statusLine = s
  586. if state.activeTab == 1 then drawSearchTab() end
  587. drawFooter()
  588. end
  589.  
  590. ------------------------------------------------------------
  591. -- 11. Playback engine (single-track, called per queue item)
  592. ------------------------------------------------------------
  593. local function stopAllSpeakers()
  594. for _, sp in ipairs(speakers) do sp.stop() end
  595. end
  596.  
  597. local function playOnAllSpeakers(pcm, vol)
  598. local pending = {}
  599. for i = 1, #speakers do pending[i] = true end
  600. while next(pending) do
  601. for i, sp in ipairs(speakers) do
  602. if pending[i] then
  603. if sp.playAudio(pcm, vol) then pending[i] = nil end
  604. end
  605. end
  606. if next(pending) then os.pullEvent("speaker_audio_empty") end
  607. end
  608. end
  609.  
  610. local function audioLoop(ws, mediaId)
  611. local decoder = dfpwm.make_decoder()
  612. local idx = 0
  613. while not state.stopFlag do
  614. send(ws, { action = "get_chunk", chunkindex = idx, id = mediaId })
  615. local msg = recv(ws, "chunk")
  616. if not msg then return end
  617. local raw = base64_decode(msg.chunk)
  618. if #raw == 0 then return end
  619. local pcm = decoder(raw)
  620. playOnAllSpeakers(pcm, state.volume)
  621. idx = idx + 1
  622. end
  623. end
  624.  
  625. local function videoLoop(ws, mediaId, mw, mh)
  626. local tracker = 0
  627. local headerSeen, fpsSeen = false, false
  628. local fps = 24
  629. local frame_count = 0
  630. local startEpoch
  631. while not state.stopFlag do
  632. send(ws, {
  633. action = "get_vid",
  634. tracker = tracker,
  635. id = mediaId,
  636. width = mw * 2,
  637. height = mh * 3,
  638. })
  639. local msg = recv(ws, "vid")
  640. if not msg then return end
  641. local lines = msg.lines
  642. if not lines or #lines == 0 then return end
  643. for _, line in ipairs(lines) do
  644. tracker = tracker + #line + 1
  645. if not headerSeen then
  646. if line == "32Vid 1.1" then headerSeen = true else return end
  647. elseif not fpsSeen then
  648. fps = tonumber(line) or 24; fpsSeen = true
  649. startEpoch = os.epoch("utc")
  650. elseif line == "" then
  651. return
  652. else
  653. if state.stopFlag then return end
  654. frame_count = frame_count + 1
  655. pcall(decodeFrame, line, monitor)
  656. if fps > 0 and startEpoch then
  657. local target = startEpoch + (frame_count + 1) / fps * 1000
  658. while os.epoch("utc") < target do
  659. if state.stopFlag then return end
  660. sleep(1 / fps)
  661. end
  662. end
  663. end
  664. end
  665. end
  666. end
  667.  
  668. local function playOne(query)
  669. state.stopFlag = false
  670. setStatus("Connecting to YouCube...")
  671. local ws, err = websocket_with_timeout(SERVER, CONNECT_TIMEOUT)
  672. if not ws then
  673. setStatus("Connect failed: " .. tostring(err))
  674. return false
  675. end
  676.  
  677. send(ws, { action = "handshake" })
  678. local hs = recv(ws, "handshake")
  679. if not hs then
  680. setStatus("Handshake failed (server may be misconfigured)")
  681. pcall(ws.close)
  682. return false
  683. end
  684.  
  685. setStatus("Requesting media (first run = transcoding takes a while)...")
  686. send(ws, {
  687. action = "request_media",
  688. url = query,
  689. width = MW * 2,
  690. height = MH * 3,
  691. })
  692. local media, merr = recv(ws, "media")
  693. if not media then
  694. setStatus("Media error: " .. tostring(merr))
  695. pcall(ws.close)
  696. return false
  697. end
  698.  
  699. state.nowPlaying = {
  700. title = media.title or media.id,
  701. id = media.id,
  702. started = os.epoch("utc"),
  703. }
  704. pushHistory({ query = query, title = state.nowPlaying.title, when = state.nowPlaying.started })
  705. setStatus("Playing: " .. state.nowPlaying.title)
  706. redraw()
  707.  
  708. monitor.setBackgroundColor(colors.black)
  709. monitor.clear()
  710.  
  711. parallel.waitForAny(
  712. function() audioLoop(ws, media.id) end,
  713. function() videoLoop(ws, media.id, MW, MH) end,
  714. function()
  715. -- Watch for stopFlag flipping (set by event handler)
  716. while not state.stopFlag do sleep(0.2) end
  717. end
  718. )
  719.  
  720. state.stopFlag = true
  721. stopAllSpeakers()
  722. pcall(ws.close)
  723. reset_palette(monitor)
  724. state.nowPlaying = nil
  725. return true
  726. end
  727.  
  728. ------------------------------------------------------------
  729. -- 12. Player loop (drains the queue forever, idles when empty)
  730. ------------------------------------------------------------
  731. local function playerLoop()
  732. while not state.quitFlag do
  733. if #state.queue > 0 then
  734. local item = table.remove(state.queue, 1)
  735. local query = item.query
  736. redraw()
  737. local ok, result = pcall(playOne, query)
  738. if not ok then
  739. setStatus("Crash: " .. tostring(result):sub(1, 60))
  740. stopAllSpeakers()
  741. state.nowPlaying = nil
  742. reset_palette(monitor)
  743. elseif result and state.loop then
  744. -- requeue same video at end
  745. table.insert(state.queue, { query = query, title = item.title })
  746. end
  747. redraw()
  748. else
  749. sleep(0.5)
  750. end
  751. end
  752. end
  753.  
  754. ------------------------------------------------------------
  755. -- 13. Search submission helper (called by eventLoop on Enter in tab 1)
  756. ------------------------------------------------------------
  757. local function submitSearch()
  758. local line = state.searchBuffer
  759. state.searchBuffer = ""
  760. if not line or line == "" then drawSearchTab(); return end
  761. local queueIt = false
  762. if line:sub(1, 2):lower() == "q " then
  763. queueIt = true
  764. line = line:sub(3)
  765. end
  766. if queueIt then
  767. table.insert(state.queue, { query = line, title = line })
  768. setStatus("Queued: " .. line)
  769. else
  770. -- Insert at front; if currently playing, stop so the new one starts
  771. table.insert(state.queue, 1, { query = line, title = line })
  772. if state.nowPlaying then state.stopFlag = true end
  773. setStatus("Playing: " .. line)
  774. end
  775. drawSearchTab()
  776. drawFooter()
  777. end
  778.  
  779. ------------------------------------------------------------
  780. -- 14. Event router (keys + char + paste + monitor touches)
  781. --
  782. -- Tab switching:
  783. -- * Tab key always cycles forward (1->2->3->4->1)
  784. -- * Number keys 1-4 switch tabs only when NOT in tab 1 (so typing in
  785. -- the search buffer doesn't accidentally jump tabs)
  786. -- * Q quits (uppercase Q via shift+q, OR lowercase q outside tab 1)
  787. -- -- in tab 1, lowercase q is text input; use Tab to leave first.
  788. ------------------------------------------------------------
  789.  
  790. local function eventLoop()
  791. while not state.quitFlag do
  792. local ev, p1, p2, p3 = os.pullEvent()
  793.  
  794. if ev == "key" then
  795. local k = p1
  796. -- Universal: Tab cycles forward
  797. if k == keys.tab then
  798. switchTab((state.activeTab % 4) + 1); redraw()
  799. -- Search-tab-specific keys (typing context)
  800. elseif state.activeTab == 1 then
  801. if k == keys.enter then
  802. submitSearch()
  803. elseif k == keys.backspace then
  804. if #state.searchBuffer > 0 then
  805. state.searchBuffer = state.searchBuffer:sub(1, -2)
  806. drawSearchTab()
  807. end
  808. -- ESC clears
  809. elseif keys.getName and keys.getName(k) == "leftCtrl" then
  810. -- ignore
  811. end
  812. -- All other keys ignored in tab 1; chars handled by "char" event
  813. else
  814. -- Outside tab 1: number keys switch tabs
  815. if k == keys.one then switchTab(1); redraw()
  816. elseif k == keys.two then switchTab(2); redraw()
  817. elseif k == keys.three then switchTab(3); redraw()
  818. elseif k == keys.four then switchTab(4); redraw()
  819. elseif k == keys.q then state.quitFlag = true; state.stopFlag = true
  820. elseif state.activeTab == 2 then -- Now Playing
  821. if k == keys.s then state.stopFlag = true
  822. elseif k == keys.n then state.stopFlag = true
  823. elseif k == keys.l then state.loop = not state.loop; drawNowPlayingTab(); drawFooter()
  824. elseif k == keys.equals or k == keys.numPadAdd then
  825. state.volume = math.min(3.0, state.volume + 0.25); drawNowPlayingTab(); drawFooter()
  826. elseif k == keys.minus or k == keys.numPadSubtract then
  827. state.volume = math.max(0.0, state.volume - 0.25); drawNowPlayingTab(); drawFooter()
  828. end
  829. elseif state.activeTab == 3 then -- Queue
  830. if k == keys.up then state.queueCursor = math.max(1, state.queueCursor - 1); drawQueueTab()
  831. elseif k == keys.down then state.queueCursor = math.min(#state.queue, state.queueCursor + 1); drawQueueTab()
  832. elseif k == keys.enter and #state.queue > 0 then
  833. local item = table.remove(state.queue, state.queueCursor)
  834. table.insert(state.queue, 1, item)
  835. if state.nowPlaying then state.stopFlag = true end
  836. elseif k == keys.j and state.queueCursor < #state.queue then
  837. state.queue[state.queueCursor], state.queue[state.queueCursor + 1] =
  838. state.queue[state.queueCursor + 1], state.queue[state.queueCursor]
  839. state.queueCursor = state.queueCursor + 1; drawQueueTab()
  840. elseif k == keys.k and state.queueCursor > 1 then
  841. state.queue[state.queueCursor], state.queue[state.queueCursor - 1] =
  842. state.queue[state.queueCursor - 1], state.queue[state.queueCursor]
  843. state.queueCursor = state.queueCursor - 1; drawQueueTab()
  844. elseif k == keys.d and #state.queue > 0 then
  845. table.remove(state.queue, state.queueCursor); drawQueueTab(); drawFooter()
  846. elseif k == keys.c then
  847. state.queue = {}; drawQueueTab(); drawFooter()
  848. end
  849. elseif state.activeTab == 4 then -- History
  850. if k == keys.up then state.historyCursor = math.max(1, state.historyCursor - 1); drawHistoryTab()
  851. elseif k == keys.down then state.historyCursor = math.min(#state.history, state.historyCursor + 1); drawHistoryTab()
  852. elseif k == keys.enter and #state.history > 0 then
  853. local item = state.history[state.historyCursor]
  854. table.insert(state.queue, 1, { query = item.query, title = item.title })
  855. if state.nowPlaying then state.stopFlag = true end
  856. elseif k == keys.a and #state.history > 0 then
  857. local item = state.history[state.historyCursor]
  858. table.insert(state.queue, { query = item.query, title = item.title })
  859. drawFooter()
  860. elseif k == keys.d and #state.history > 0 then
  861. table.remove(state.history, state.historyCursor); saveHistory(); drawHistoryTab()
  862. elseif k == keys.c then
  863. state.history = {}; saveHistory(); drawHistoryTab()
  864. end
  865. end
  866. end
  867.  
  868. elseif ev == "char" then
  869. -- Text input only goes to the search buffer when tab 1 is active
  870. if state.activeTab == 1 then
  871. state.searchBuffer = state.searchBuffer .. p1
  872. drawSearchTab()
  873. end
  874.  
  875. elseif ev == "paste" then
  876. -- Pasted text into search buffer
  877. if state.activeTab == 1 and type(p1) == "string" then
  878. state.searchBuffer = state.searchBuffer .. p1
  879. drawSearchTab()
  880. end
  881.  
  882. elseif ev == "mouse_click" then
  883. -- p1=button, p2=x, p3=y. Dispatch to registered hitboxes.
  884. dispatchHit(p2, p3)
  885.  
  886. elseif ev == "monitor_touch" then
  887. -- Tap monitor while playing = stop
  888. if state.nowPlaying then state.stopFlag = true end
  889. end
  890. end
  891. end
  892.  
  893. ------------------------------------------------------------
  894. -- 15. Footer ticker (so elapsed time updates)
  895. ------------------------------------------------------------
  896. local function footerTicker()
  897. while not state.quitFlag do
  898. sleep(1)
  899. if state.nowPlaying then
  900. drawFooter()
  901. if state.activeTab == 2 then drawNowPlayingTab() end
  902. end
  903. end
  904. end
  905.  
  906. ------------------------------------------------------------
  907. -- 16. Boot
  908. ------------------------------------------------------------
  909. term.clear()
  910. redraw()
  911.  
  912. local ok, err = pcall(parallel.waitForAny,
  913. eventLoop,
  914. playerLoop,
  915. footerTicker
  916. )
  917.  
  918. stopAllSpeakers()
  919. reset_palette(monitor)
  920. term.setBackgroundColor(colors.black)
  921. term.setTextColor(colors.white)
  922. term.clear()
  923. term.setCursorPos(1, 1)
  924. print("ATM-Tube exited.")
  925. if not ok then printError(tostring(err)) end
Add Comment
Please, Sign In to add comment