Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- --[[
- ATM-Tube v2 — Tabbed YouTube player for CC:Tweaked
- ----------------------------------------------------
- Streams via self-hosted YouCube (ws://192.168.1.22:5000), renders 32vid frames
- to monitor, plays DFPWM audio across ALL connected speakers in parallel.
- Tabs (switch with Tab key, or number keys 1-4 outside the Search tab):
- [1] Search — type a query/URL, Enter to play (prefix 'q ' to queue)
- [2] Now Playing — title, time, controls (Stop/Skip/Loop, +/- volume)
- [3] Queue — upcoming videos, manage with arrows + J/K/D/C
- [4] History — recently played, R to replay, A to add to queue
- Architecture:
- parallel.waitForAny(
- eventLoop, -- routes keys/touches to active tab; chars feed search buf
- playerLoop, -- pulls audio + video from YouCube, drives speakers/monitor
- footerTicker -- redraws footer every second so elapsed time updates
- )
- Hardware: same as v1 (advanced computer + monitor + 1+ speakers).
- ]]--
- ------------------------------------------------------------
- -- 0. Environment checks
- ------------------------------------------------------------
- if not http or not http.websocket then
- error("HTTP API disabled. Enable in CC:Tweaked config.")
- end
- local has_dfpwm, dfpwm = pcall(require, "cc.audio.dfpwm")
- if not has_dfpwm then
- error("cc.audio.dfpwm unavailable. Need CC:Tweaked >= 1.100.")
- end
- ------------------------------------------------------------
- -- 1. Configuration
- ------------------------------------------------------------
- local SERVER = "ws://192.168.1.15:5000" -- desktop YouCube (start with `yc-on`)
- local CONNECT_TIMEOUT = 5
- local DEFAULT_VOLUME = 1.0
- local HISTORY_PATH = "atmtube_history.txt"
- local HISTORY_MAX = 25
- local STRING_PACK_URL = "https://gist.githubusercontent.com/MCJack123/d5973e4d8b7e46991c5f99ac4b076aec/raw/string_pack.lua"
- local STRING_PACK_PATH = "lib/string_pack.lua"
- ------------------------------------------------------------
- -- 2. Peripherals (same discovery as v1)
- ------------------------------------------------------------
- local function findAllSpeakers()
- local list = {}
- for _, name in ipairs(peripheral.getNames()) do
- if peripheral.getType(name) == "speaker" then
- table.insert(list, peripheral.wrap(name))
- end
- end
- return list
- end
- local function findBestMonitor()
- local best, bestArea = nil, 0
- for _, name in ipairs(peripheral.getNames()) do
- if peripheral.getType(name) == "monitor" then
- local m = peripheral.wrap(name)
- m.setTextScale(0.5)
- local w, h = m.getSize()
- if w * h > bestArea then best, bestArea = m, w * h end
- end
- end
- return best
- end
- local speakers = findAllSpeakers()
- local monitor = findBestMonitor()
- if #speakers == 0 then printError("No speakers found."); return end
- if not monitor then printError("No monitor found."); return end
- monitor.setTextScale(0.5)
- ------------------------------------------------------------
- -- 3. string.unpack polyfill bootstrap (same as v1)
- ------------------------------------------------------------
- local string_unpack
- if string.unpack then
- string_unpack = string.unpack
- else
- if not fs.exists(STRING_PACK_PATH) then
- if not fs.exists("lib") then fs.makeDir("lib") end
- local resp, err = http.get(STRING_PACK_URL)
- if not resp then error("Cannot fetch string_pack: " .. tostring(err)) end
- local f = fs.open(STRING_PACK_PATH, "w")
- f.write(resp.readAll()); f.close(); resp.close()
- end
- local sp = dofile(shell.resolve(STRING_PACK_PATH))
- if type(sp) == "table" and sp.unpack then
- string_unpack = sp.unpack
- elseif string.unpack then
- string_unpack = string.unpack
- else
- error("string_pack.lua loaded but no .unpack")
- end
- end
- ------------------------------------------------------------
- -- 4. Base64 + 32vid decoder (verbatim from v1)
- ------------------------------------------------------------
- local b64str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
- local function base64_decode(str)
- local retval = ""
- for s in str:gmatch("....") do
- if s:sub(3, 4) == "==" then
- retval = retval .. string.char(bit32.bor(
- bit32.lshift(b64str:find(s:sub(1, 1)) - 1, 2),
- bit32.rshift(b64str:find(s:sub(2, 2)) - 1, 4)))
- elseif s:sub(4, 4) == "=" then
- local n = (b64str:find(s:sub(1, 1)) - 1) * 4096
- + (b64str:find(s:sub(2, 2)) - 1) * 64
- + (b64str:find(s:sub(3, 3)) - 1)
- retval = retval .. string.char(bit32.extract(n, 10, 8))
- .. string.char(bit32.extract(n, 2, 8))
- else
- local n = (b64str:find(s:sub(1, 1)) - 1) * 262144
- + (b64str:find(s:sub(2, 2)) - 1) * 4096
- + (b64str:find(s:sub(3, 3)) - 1) * 64
- + (b64str:find(s:sub(4, 4)) - 1)
- retval = retval .. string.char(bit32.extract(n, 16, 8))
- .. string.char(bit32.extract(n, 8, 8))
- .. string.char(bit32.extract(n, 0, 8))
- end
- end
- return retval
- end
- local function decodeFrame(frame, target)
- local mode = frame:match("^!CP([CD])")
- if not mode then return nil end
- local b64data
- if mode == "C" then
- local len = tonumber(frame:sub(5, 8), 16)
- if not len then return nil end
- b64data = frame:sub(9, len + 8)
- else
- local len = tonumber(frame:sub(5, 16), 16)
- if not len then return nil end
- b64data = frame:sub(17, len + 16)
- end
- local data = base64_decode(b64data)
- if data:sub(1, 4) ~= "\0\0\0\0" or data:sub(9, 16) ~= "\0\0\0\0\0\0\0\0" then
- return nil
- end
- local width, height = string_unpack("HH", data, 5)
- local c, n, pos = string_unpack("c1B", data, 17)
- local text = {}
- for y = 1, height do
- text[y] = ""
- for _ = 1, width do
- text[y] = text[y] .. c
- n = n - 1
- if n == 0 then c, n, pos = string_unpack("c1B", data, pos) end
- end
- end
- c = c:byte()
- for y = 1, height do
- local fg, bg = "", ""
- for _ = 1, width do
- fg = fg .. ("%x"):format(bit32.band(c, 0x0F))
- bg = bg .. ("%x"):format(bit32.rshift(c, 4))
- n = n - 1
- if n == 0 then c, n, pos = string_unpack("BB", data, pos) end
- end
- target.setCursorPos(1, y)
- target.blit(text[y], fg, bg)
- end
- pos = pos - 2
- for i = 0, 15 do
- local r, g, b
- r, g, b, pos = string_unpack("BBB", data, pos)
- target.setPaletteColor(2 ^ i, r / 255, g / 255, b / 255)
- end
- return true
- end
- local function reset_palette(t)
- for i = 0, 15 do
- t.setPaletteColor(2 ^ i, term.nativePaletteColor(2 ^ i))
- end
- t.setBackgroundColor(colors.black)
- t.setTextColor(colors.white)
- t.clear()
- t.setCursorPos(1, 1)
- end
- ------------------------------------------------------------
- -- 5. WebSocket helpers
- ------------------------------------------------------------
- local function websocket_with_timeout(url, timeout)
- if not http.websocketAsync then return http.websocket(url) end
- local ws, err = http.websocketAsync(url)
- if not ws then return false, err end
- local timerId = os.startTimer(timeout)
- while true do
- local ev, p1, p2 = os.pullEvent()
- if ev == "websocket_success" and p1 == url then
- os.cancelTimer(timerId); return p2
- elseif ev == "websocket_failure" and p1 == url then
- os.cancelTimer(timerId); return false, p2
- elseif ev == "timer" and p1 == timerId then
- return false, "timeout"
- end
- end
- end
- local function send(ws, tbl) ws.send(textutils.serialiseJSON(tbl)) end
- -- Receive with a timeout. Returns nil, "timeout" if no message arrives in
- -- `timeout` seconds. ws.receive(timeout) is documented in CC:Tweaked.
- local RECV_TIMEOUT = 30 -- generous for first-run transcodes
- local function recv(ws, filter)
- while true do
- local raw, err = ws.receive(RECV_TIMEOUT)
- if not raw then
- return nil, err == nil and "timeout" or err
- end
- local msg = textutils.unserialiseJSON(raw)
- if not msg then return nil, "malformed JSON" end
- if msg.action == "error" then return nil, msg.message end
- if not filter or msg.action == filter then return msg end
- -- Status / progress messages fall through; loop and re-receive
- end
- end
- ------------------------------------------------------------
- -- 6. State
- ------------------------------------------------------------
- local TW, TH = term.getSize()
- local MW, MH = monitor.getSize()
- local state = {
- activeTab = 1,
- volume = DEFAULT_VOLUME,
- queue = {}, -- list of {query=..., title=...}
- history = {}, -- list of {query=..., title=..., when=...}
- nowPlaying = nil, -- {title=..., id=..., started=epoch_ms, duration=nil}
- queueCursor = 1, -- highlighted row in Queue tab
- historyCursor = 1, -- highlighted row in History tab
- loop = false, -- if true, finished video re-queues itself
- stopFlag = false, -- stop CURRENT playback
- quitFlag = false, -- exit whole app
- statusLine = "Ready.",
- searchBuffer = "", -- live search-input text (Tab 1 only)
- }
- ------------------------------------------------------------
- -- 7. History persistence
- ------------------------------------------------------------
- local function loadHistory()
- if not fs.exists(HISTORY_PATH) then return end
- local f = fs.open(HISTORY_PATH, "r")
- local raw = f.readAll(); f.close()
- local ok, list = pcall(textutils.unserialise, raw)
- if ok and type(list) == "table" then state.history = list end
- end
- local function saveHistory()
- local f = fs.open(HISTORY_PATH, "w")
- f.write(textutils.serialise(state.history)); f.close()
- end
- local function pushHistory(entry)
- -- de-dupe (move to top if already present)
- for i = #state.history, 1, -1 do
- if state.history[i].query == entry.query then
- table.remove(state.history, i)
- end
- end
- table.insert(state.history, 1, entry)
- while #state.history > HISTORY_MAX do table.remove(state.history) end
- saveHistory()
- end
- loadHistory()
- ------------------------------------------------------------
- -- 8. Tab windows
- -- One window per tab, all stacked at same coords (header line 1, body lines
- -- 2..TH-1, footer line TH). We toggle .setVisible() to switch tabs.
- ------------------------------------------------------------
- local headerWin = window.create(term.current(), 1, 1, TW, 1, true)
- local bodyWins = {
- window.create(term.current(), 1, 2, TW, TH - 2, true),
- window.create(term.current(), 1, 2, TW, TH - 2, false),
- window.create(term.current(), 1, 2, TW, TH - 2, false),
- window.create(term.current(), 1, 2, TW, TH - 2, false),
- }
- local footerWin = window.create(term.current(), 1, TH, TW, 1, true)
- local TAB_NAMES = { "Search", "Playing", "Queue", "History" }
- -- Forward declarations: closures inside drawHeader/drawNowPlayingTab/etc.
- -- reference redraw() before its actual definition further down. Lua resolves
- -- upvalues at call-time, not declaration-time, so as long as the local slot
- -- exists at closure-creation time we're fine.
- local redraw
- -- Hitbox map: { {x1, x2, y, action_fn}, ... } - rebuilt on every redraw.
- -- Used by mouse_click + monitor_touch event handlers.
- local hitboxes = {}
- local function addHit(x1, x2, y, fn)
- table.insert(hitboxes, { x1 = x1, x2 = x2, y = y, fn = fn })
- end
- local function clearHits() hitboxes = {} end
- local function dispatchHit(x, y)
- for _, h in ipairs(hitboxes) do
- if y == h.y and x >= h.x1 and x <= h.x2 then
- h.fn()
- return true
- end
- end
- return false
- end
- ------------------------------------------------------------
- -- 9. Drawing helpers
- ------------------------------------------------------------
- local function switchTab(n)
- state.activeTab = n
- -- redraw is called by caller
- end
- local function drawHeader()
- headerWin.setBackgroundColor(colors.black)
- headerWin.setTextColor(colors.white)
- headerWin.clear()
- headerWin.setCursorPos(1, 1)
- local x = 1
- -- First pass: measure how wide tabs would be, fall back to compact form if needed
- local total = 0
- for i, name in ipairs(TAB_NAMES) do
- total = total + #(" [" .. i .. "] " .. name .. " ")
- end
- local quitLabel = " [Q] Quit "
- -- If tabs + quit label don't fit, shrink labels to just the number
- local compact = total + #quitLabel > TW - 1
- for i, name in ipairs(TAB_NAMES) do
- local label = compact and ("[" .. i .. "]") or ("[" .. i .. "] " .. name)
- local cell = " " .. label .. " "
- if i == state.activeTab then
- headerWin.setBackgroundColor(colors.red)
- headerWin.setTextColor(colors.white)
- else
- headerWin.setBackgroundColor(colors.gray)
- headerWin.setTextColor(colors.lightGray)
- end
- headerWin.setCursorPos(x, 1)
- headerWin.write(cell)
- local cellEnd = x + #cell - 1
- -- Tab hitbox is on the global header row (line 1)
- local tab_i = i
- addHit(x, cellEnd, 1, function() switchTab(tab_i); redraw() end)
- x = cellEnd + 1
- end
- -- Quit at right
- headerWin.setBackgroundColor(colors.gray)
- headerWin.setTextColor(colors.lightGray)
- local qx = TW - #quitLabel + 1
- headerWin.setCursorPos(qx, 1)
- headerWin.write(quitLabel)
- addHit(qx, TW, 1, function() state.quitFlag = true; state.stopFlag = true end)
- end
- local function drawFooter()
- footerWin.setBackgroundColor(colors.black)
- footerWin.setTextColor(colors.lightGray)
- footerWin.clear()
- footerWin.setCursorPos(1, 1)
- local left
- if state.nowPlaying then
- local elapsed = math.floor((os.epoch("utc") - state.nowPlaying.started) / 1000)
- local mm = math.floor(elapsed / 60)
- local ss = elapsed % 60
- left = ("\7 %s %d:%02d"):format(state.nowPlaying.title:sub(1, 30), mm, ss)
- else
- left = state.statusLine
- end
- local right = ("V%.2f %dsp %dx%d"):format(state.volume, #speakers, MW, MH)
- footerWin.write(left)
- if #left + #right + 2 <= TW then
- footerWin.setCursorPos(TW - #right + 1, 1)
- footerWin.write(right)
- end
- end
- local function bodyOf(tab) return bodyWins[tab] end
- local function clearBody(win)
- win.setBackgroundColor(colors.black)
- win.setTextColor(colors.white)
- win.clear()
- win.setCursorPos(1, 1)
- end
- ------------------------------------------------------------
- -- 10. Tab renderers
- ------------------------------------------------------------
- local function drawSearchTab()
- local w = bodyOf(1)
- clearBody(w)
- w.setCursorPos(2, 2); w.setTextColor(colors.white)
- w.write("Search YouTube (URL, video ID, or text query):")
- w.setCursorPos(2, 4)
- w.setTextColor(colors.lightGray)
- w.write("Type then press Enter. Backspace edits. ESC clears.")
- w.setCursorPos(2, 5)
- w.write("Prefix with 'q ' to add to queue instead of playing now.")
- -- Input box
- w.setCursorPos(2, 8); w.setTextColor(colors.white)
- w.write("> ")
- w.setBackgroundColor(colors.gray); w.setTextColor(colors.white)
- local boxW = TW - 6
- local shown = state.searchBuffer
- if #shown > boxW then shown = shown:sub(#shown - boxW + 1) end
- w.write(shown .. string.rep(" ", math.max(0, boxW - #shown)))
- w.setBackgroundColor(colors.black)
- -- Blinking caret indicator
- if state.activeTab == 1 then
- w.setTextColor(colors.red)
- local caretX = 4 + math.min(#state.searchBuffer, boxW)
- w.setCursorPos(caretX, 8)
- w.write("_")
- end
- w.setCursorPos(2, 11); w.setTextColor(colors.yellow)
- w.write("Status: " .. state.statusLine:sub(1, TW - 12))
- end
- -- Button helper: draws a colored block with text, registers a hitbox.
- -- y is body-window-relative; we add +1 to convert to screen coords (header takes line 1).
- local function drawButton(w, x, y, label, bg, fg, action)
- w.setBackgroundColor(bg)
- w.setTextColor(fg or colors.white)
- w.setCursorPos(x, y)
- w.write(" " .. label .. " ")
- local screenY = y + 1
- addHit(x, x + #label + 1, screenY, action)
- w.setBackgroundColor(colors.black)
- end
- local function drawNowPlayingTab()
- local w = bodyOf(2)
- clearBody(w)
- if not state.nowPlaying then
- w.setCursorPos(2, 3); w.setTextColor(colors.lightGray)
- w.write("Nothing playing. Use Search tab to start a video.")
- return
- end
- w.setCursorPos(2, 2); w.setTextColor(colors.white)
- w.write(state.nowPlaying.title:sub(1, TW - 4))
- w.setCursorPos(2, 3); w.setTextColor(colors.gray)
- w.write("id: " .. state.nowPlaying.id)
- local elapsed = math.floor((os.epoch("utc") - state.nowPlaying.started) / 1000)
- w.setCursorPos(2, 5); w.setTextColor(colors.white)
- w.write(("Elapsed: %d:%02d"):format(math.floor(elapsed / 60), elapsed % 60))
- -- Transport buttons
- w.setCursorPos(2, 7); w.setTextColor(colors.white)
- w.write("Transport:")
- drawButton(w, 2, 9, "STOP", colors.red, colors.white,
- function() state.stopFlag = true end)
- drawButton(w, 9, 9, "SKIP", colors.orange, colors.black,
- function() state.stopFlag = true end)
- local loopLabel = state.loop and "LOOP ON " or "LOOP OFF"
- drawButton(w, 16, 9, loopLabel,
- state.loop and colors.lime or colors.gray,
- colors.black,
- function() state.loop = not state.loop; redraw() end)
- -- Volume row
- w.setCursorPos(2, 11); w.setTextColor(colors.white)
- w.write("Volume:")
- drawButton(w, 11, 11, "-", colors.gray, colors.white,
- function() state.volume = math.max(0.0, state.volume - 0.25); redraw() end)
- drawButton(w, 16, 11, "+", colors.gray, colors.white,
- function() state.volume = math.min(3.0, state.volume + 0.25); redraw() end)
- w.setCursorPos(21, 11)
- w.setTextColor(colors.lightGray)
- w.write(("%.2f / 3.00"):format(state.volume))
- w.setCursorPos(2, 14); w.setTextColor(colors.gray)
- w.write(("Queue: %d History: %d"):format(#state.queue, #state.history))
- end
- local function drawQueueTab()
- local w = bodyOf(3)
- clearBody(w)
- w.setCursorPos(2, 1); w.setTextColor(colors.white)
- w.write(("Queue (%d):"):format(#state.queue))
- if #state.queue == 0 then
- w.setCursorPos(2, 3); w.setTextColor(colors.lightGray)
- w.write("Empty. From Search tab, prefix queries with 'q '.")
- else
- if state.queueCursor > #state.queue then state.queueCursor = #state.queue end
- if state.queueCursor < 1 then state.queueCursor = 1 end
- local rows = math.min(#state.queue, TH - 5)
- for i = 1, rows do
- local item = state.queue[i]
- local bodyY = 2 + i
- w.setCursorPos(2, bodyY)
- if i == state.queueCursor then
- w.setBackgroundColor(colors.gray); w.setTextColor(colors.white)
- else
- w.setBackgroundColor(colors.black); w.setTextColor(colors.lightGray)
- end
- local line = (" %d. %s"):format(i, item.title or item.query)
- w.write(line:sub(1, TW - 2) .. string.rep(" ", math.max(0, TW - 2 - #line)))
- -- Click row = move to front + play
- local idx = i
- addHit(2, TW - 1, bodyY + 1, function()
- local it = table.remove(state.queue, idx)
- table.insert(state.queue, 1, it)
- if state.nowPlaying then state.stopFlag = true end
- redraw()
- end)
- end
- w.setBackgroundColor(colors.black)
- end
- w.setCursorPos(2, TH - 3); w.setTextColor(colors.gray)
- w.write("Click row to play Up/Dn arrows + J/K reorder D del C clear")
- end
- local function drawHistoryTab()
- local w = bodyOf(4)
- clearBody(w)
- w.setCursorPos(2, 1); w.setTextColor(colors.white)
- w.write(("History (%d):"):format(#state.history))
- if #state.history == 0 then
- w.setCursorPos(2, 3); w.setTextColor(colors.lightGray)
- w.write("No videos played yet.")
- else
- if state.historyCursor > #state.history then state.historyCursor = #state.history end
- if state.historyCursor < 1 then state.historyCursor = 1 end
- local rows = math.min(#state.history, TH - 5)
- for i = 1, rows do
- local item = state.history[i]
- local bodyY = 2 + i
- w.setCursorPos(2, bodyY)
- if i == state.historyCursor then
- w.setBackgroundColor(colors.gray); w.setTextColor(colors.white)
- else
- w.setBackgroundColor(colors.black); w.setTextColor(colors.lightGray)
- end
- local line = (" %d. %s"):format(i, item.title or item.query)
- w.write(line:sub(1, TW - 2) .. string.rep(" ", math.max(0, TW - 2 - #line)))
- -- Click row = replay
- local idx = i
- addHit(2, TW - 1, bodyY + 1, function()
- local it = state.history[idx]
- table.insert(state.queue, 1, { query = it.query, title = it.title })
- if state.nowPlaying then state.stopFlag = true end
- redraw()
- end)
- end
- w.setBackgroundColor(colors.black)
- end
- w.setCursorPos(2, TH - 3); w.setTextColor(colors.gray)
- w.write("Click row to replay Arrows highlight A queue D forget C clear")
- end
- local TAB_DRAWERS = { drawSearchTab, drawNowPlayingTab, drawQueueTab, drawHistoryTab }
- redraw = function()
- clearHits()
- drawHeader()
- -- toggle window visibility
- for i, w in ipairs(bodyWins) do w.setVisible(i == state.activeTab) end
- TAB_DRAWERS[state.activeTab]()
- drawFooter()
- end
- local function setStatus(s)
- state.statusLine = s
- if state.activeTab == 1 then drawSearchTab() end
- drawFooter()
- end
- ------------------------------------------------------------
- -- 11. Playback engine (single-track, called per queue item)
- ------------------------------------------------------------
- local function stopAllSpeakers()
- for _, sp in ipairs(speakers) do sp.stop() end
- end
- local function playOnAllSpeakers(pcm, vol)
- local pending = {}
- for i = 1, #speakers do pending[i] = true end
- while next(pending) do
- for i, sp in ipairs(speakers) do
- if pending[i] then
- if sp.playAudio(pcm, vol) then pending[i] = nil end
- end
- end
- if next(pending) then os.pullEvent("speaker_audio_empty") end
- end
- end
- local function audioLoop(ws, mediaId)
- local decoder = dfpwm.make_decoder()
- local idx = 0
- while not state.stopFlag do
- send(ws, { action = "get_chunk", chunkindex = idx, id = mediaId })
- local msg = recv(ws, "chunk")
- if not msg then return end
- local raw = base64_decode(msg.chunk)
- if #raw == 0 then return end
- local pcm = decoder(raw)
- playOnAllSpeakers(pcm, state.volume)
- idx = idx + 1
- end
- end
- local function videoLoop(ws, mediaId, mw, mh)
- local tracker = 0
- local headerSeen, fpsSeen = false, false
- local fps = 24
- local frame_count = 0
- local startEpoch
- while not state.stopFlag do
- send(ws, {
- action = "get_vid",
- tracker = tracker,
- id = mediaId,
- width = mw * 2,
- height = mh * 3,
- })
- local msg = recv(ws, "vid")
- if not msg then return end
- local lines = msg.lines
- if not lines or #lines == 0 then return end
- for _, line in ipairs(lines) do
- tracker = tracker + #line + 1
- if not headerSeen then
- if line == "32Vid 1.1" then headerSeen = true else return end
- elseif not fpsSeen then
- fps = tonumber(line) or 24; fpsSeen = true
- startEpoch = os.epoch("utc")
- elseif line == "" then
- return
- else
- if state.stopFlag then return end
- frame_count = frame_count + 1
- pcall(decodeFrame, line, monitor)
- if fps > 0 and startEpoch then
- local target = startEpoch + (frame_count + 1) / fps * 1000
- while os.epoch("utc") < target do
- if state.stopFlag then return end
- sleep(1 / fps)
- end
- end
- end
- end
- end
- end
- local function playOne(query)
- state.stopFlag = false
- setStatus("Connecting to YouCube...")
- local ws, err = websocket_with_timeout(SERVER, CONNECT_TIMEOUT)
- if not ws then
- setStatus("Connect failed: " .. tostring(err))
- return false
- end
- send(ws, { action = "handshake" })
- local hs = recv(ws, "handshake")
- if not hs then
- setStatus("Handshake failed (server may be misconfigured)")
- pcall(ws.close)
- return false
- end
- setStatus("Requesting media (first run = transcoding takes a while)...")
- send(ws, {
- action = "request_media",
- url = query,
- width = MW * 2,
- height = MH * 3,
- })
- local media, merr = recv(ws, "media")
- if not media then
- setStatus("Media error: " .. tostring(merr))
- pcall(ws.close)
- return false
- end
- state.nowPlaying = {
- title = media.title or media.id,
- id = media.id,
- started = os.epoch("utc"),
- }
- pushHistory({ query = query, title = state.nowPlaying.title, when = state.nowPlaying.started })
- setStatus("Playing: " .. state.nowPlaying.title)
- redraw()
- monitor.setBackgroundColor(colors.black)
- monitor.clear()
- parallel.waitForAny(
- function() audioLoop(ws, media.id) end,
- function() videoLoop(ws, media.id, MW, MH) end,
- function()
- -- Watch for stopFlag flipping (set by event handler)
- while not state.stopFlag do sleep(0.2) end
- end
- )
- state.stopFlag = true
- stopAllSpeakers()
- pcall(ws.close)
- reset_palette(monitor)
- state.nowPlaying = nil
- return true
- end
- ------------------------------------------------------------
- -- 12. Player loop (drains the queue forever, idles when empty)
- ------------------------------------------------------------
- local function playerLoop()
- while not state.quitFlag do
- if #state.queue > 0 then
- local item = table.remove(state.queue, 1)
- local query = item.query
- redraw()
- local ok, result = pcall(playOne, query)
- if not ok then
- setStatus("Crash: " .. tostring(result):sub(1, 60))
- stopAllSpeakers()
- state.nowPlaying = nil
- reset_palette(monitor)
- elseif result and state.loop then
- -- requeue same video at end
- table.insert(state.queue, { query = query, title = item.title })
- end
- redraw()
- else
- sleep(0.5)
- end
- end
- end
- ------------------------------------------------------------
- -- 13. Search submission helper (called by eventLoop on Enter in tab 1)
- ------------------------------------------------------------
- local function submitSearch()
- local line = state.searchBuffer
- state.searchBuffer = ""
- if not line or line == "" then drawSearchTab(); return end
- local queueIt = false
- if line:sub(1, 2):lower() == "q " then
- queueIt = true
- line = line:sub(3)
- end
- if queueIt then
- table.insert(state.queue, { query = line, title = line })
- setStatus("Queued: " .. line)
- else
- -- Insert at front; if currently playing, stop so the new one starts
- table.insert(state.queue, 1, { query = line, title = line })
- if state.nowPlaying then state.stopFlag = true end
- setStatus("Playing: " .. line)
- end
- drawSearchTab()
- drawFooter()
- end
- ------------------------------------------------------------
- -- 14. Event router (keys + char + paste + monitor touches)
- --
- -- Tab switching:
- -- * Tab key always cycles forward (1->2->3->4->1)
- -- * Number keys 1-4 switch tabs only when NOT in tab 1 (so typing in
- -- the search buffer doesn't accidentally jump tabs)
- -- * Q quits (uppercase Q via shift+q, OR lowercase q outside tab 1)
- -- -- in tab 1, lowercase q is text input; use Tab to leave first.
- ------------------------------------------------------------
- local function eventLoop()
- while not state.quitFlag do
- local ev, p1, p2, p3 = os.pullEvent()
- if ev == "key" then
- local k = p1
- -- Universal: Tab cycles forward
- if k == keys.tab then
- switchTab((state.activeTab % 4) + 1); redraw()
- -- Search-tab-specific keys (typing context)
- elseif state.activeTab == 1 then
- if k == keys.enter then
- submitSearch()
- elseif k == keys.backspace then
- if #state.searchBuffer > 0 then
- state.searchBuffer = state.searchBuffer:sub(1, -2)
- drawSearchTab()
- end
- -- ESC clears
- elseif keys.getName and keys.getName(k) == "leftCtrl" then
- -- ignore
- end
- -- All other keys ignored in tab 1; chars handled by "char" event
- else
- -- Outside tab 1: number keys switch tabs
- if k == keys.one then switchTab(1); redraw()
- elseif k == keys.two then switchTab(2); redraw()
- elseif k == keys.three then switchTab(3); redraw()
- elseif k == keys.four then switchTab(4); redraw()
- elseif k == keys.q then state.quitFlag = true; state.stopFlag = true
- elseif state.activeTab == 2 then -- Now Playing
- if k == keys.s then state.stopFlag = true
- elseif k == keys.n then state.stopFlag = true
- elseif k == keys.l then state.loop = not state.loop; drawNowPlayingTab(); drawFooter()
- elseif k == keys.equals or k == keys.numPadAdd then
- state.volume = math.min(3.0, state.volume + 0.25); drawNowPlayingTab(); drawFooter()
- elseif k == keys.minus or k == keys.numPadSubtract then
- state.volume = math.max(0.0, state.volume - 0.25); drawNowPlayingTab(); drawFooter()
- end
- elseif state.activeTab == 3 then -- Queue
- if k == keys.up then state.queueCursor = math.max(1, state.queueCursor - 1); drawQueueTab()
- elseif k == keys.down then state.queueCursor = math.min(#state.queue, state.queueCursor + 1); drawQueueTab()
- elseif k == keys.enter and #state.queue > 0 then
- local item = table.remove(state.queue, state.queueCursor)
- table.insert(state.queue, 1, item)
- if state.nowPlaying then state.stopFlag = true end
- elseif k == keys.j and state.queueCursor < #state.queue then
- state.queue[state.queueCursor], state.queue[state.queueCursor + 1] =
- state.queue[state.queueCursor + 1], state.queue[state.queueCursor]
- state.queueCursor = state.queueCursor + 1; drawQueueTab()
- elseif k == keys.k and state.queueCursor > 1 then
- state.queue[state.queueCursor], state.queue[state.queueCursor - 1] =
- state.queue[state.queueCursor - 1], state.queue[state.queueCursor]
- state.queueCursor = state.queueCursor - 1; drawQueueTab()
- elseif k == keys.d and #state.queue > 0 then
- table.remove(state.queue, state.queueCursor); drawQueueTab(); drawFooter()
- elseif k == keys.c then
- state.queue = {}; drawQueueTab(); drawFooter()
- end
- elseif state.activeTab == 4 then -- History
- if k == keys.up then state.historyCursor = math.max(1, state.historyCursor - 1); drawHistoryTab()
- elseif k == keys.down then state.historyCursor = math.min(#state.history, state.historyCursor + 1); drawHistoryTab()
- elseif k == keys.enter and #state.history > 0 then
- local item = state.history[state.historyCursor]
- table.insert(state.queue, 1, { query = item.query, title = item.title })
- if state.nowPlaying then state.stopFlag = true end
- elseif k == keys.a and #state.history > 0 then
- local item = state.history[state.historyCursor]
- table.insert(state.queue, { query = item.query, title = item.title })
- drawFooter()
- elseif k == keys.d and #state.history > 0 then
- table.remove(state.history, state.historyCursor); saveHistory(); drawHistoryTab()
- elseif k == keys.c then
- state.history = {}; saveHistory(); drawHistoryTab()
- end
- end
- end
- elseif ev == "char" then
- -- Text input only goes to the search buffer when tab 1 is active
- if state.activeTab == 1 then
- state.searchBuffer = state.searchBuffer .. p1
- drawSearchTab()
- end
- elseif ev == "paste" then
- -- Pasted text into search buffer
- if state.activeTab == 1 and type(p1) == "string" then
- state.searchBuffer = state.searchBuffer .. p1
- drawSearchTab()
- end
- elseif ev == "mouse_click" then
- -- p1=button, p2=x, p3=y. Dispatch to registered hitboxes.
- dispatchHit(p2, p3)
- elseif ev == "monitor_touch" then
- -- Tap monitor while playing = stop
- if state.nowPlaying then state.stopFlag = true end
- end
- end
- end
- ------------------------------------------------------------
- -- 15. Footer ticker (so elapsed time updates)
- ------------------------------------------------------------
- local function footerTicker()
- while not state.quitFlag do
- sleep(1)
- if state.nowPlaying then
- drawFooter()
- if state.activeTab == 2 then drawNowPlayingTab() end
- end
- end
- end
- ------------------------------------------------------------
- -- 16. Boot
- ------------------------------------------------------------
- term.clear()
- redraw()
- local ok, err = pcall(parallel.waitForAny,
- eventLoop,
- playerLoop,
- footerTicker
- )
- stopAllSpeakers()
- reset_palette(monitor)
- term.setBackgroundColor(colors.black)
- term.setTextColor(colors.white)
- term.clear()
- term.setCursorPos(1, 1)
- print("ATM-Tube exited.")
- if not ok then printError(tostring(err)) end
Add Comment
Please, Sign In to add comment