experiments of a tiny cytube-like player with yt-dlp
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: pull out js from the HTML

karitham (May 20, 2026, 1:17 AM +0200) 19ba0dfc fb5404c4

+1322 -771
+80
Cargo.lock
··· 1108 1108 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1109 1109 1110 1110 [[package]] 1111 + name = "mime_guess" 1112 + version = "2.0.5" 1113 + source = "registry+https://github.com/rust-lang/crates.io-index" 1114 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 1115 + dependencies = [ 1116 + "mime", 1117 + "unicase", 1118 + ] 1119 + 1120 + [[package]] 1111 1121 name = "mio" 1112 1122 version = "1.2.0" 1113 1123 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1130 1140 "chrono", 1131 1141 "clap", 1132 1142 "futures-util", 1143 + "mime_guess", 1144 + "rust-embed", 1133 1145 "serde", 1134 1146 "serde_json", 1135 1147 "sqlx", ··· 1464 1476 ] 1465 1477 1466 1478 [[package]] 1479 + name = "rust-embed" 1480 + version = "8.11.0" 1481 + source = "registry+https://github.com/rust-lang/crates.io-index" 1482 + checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" 1483 + dependencies = [ 1484 + "rust-embed-impl", 1485 + "rust-embed-utils", 1486 + "walkdir", 1487 + ] 1488 + 1489 + [[package]] 1490 + name = "rust-embed-impl" 1491 + version = "8.11.0" 1492 + source = "registry+https://github.com/rust-lang/crates.io-index" 1493 + checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" 1494 + dependencies = [ 1495 + "proc-macro2", 1496 + "quote", 1497 + "rust-embed-utils", 1498 + "syn", 1499 + "walkdir", 1500 + ] 1501 + 1502 + [[package]] 1503 + name = "rust-embed-utils" 1504 + version = "8.11.0" 1505 + source = "registry+https://github.com/rust-lang/crates.io-index" 1506 + checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" 1507 + dependencies = [ 1508 + "sha2", 1509 + "walkdir", 1510 + ] 1511 + 1512 + [[package]] 1467 1513 name = "rustc-hash" 1468 1514 version = "2.1.2" 1469 1515 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1480 1526 version = "1.0.23" 1481 1527 source = "registry+https://github.com/rust-lang/crates.io-index" 1482 1528 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 1529 + 1530 + [[package]] 1531 + name = "same-file" 1532 + version = "1.0.6" 1533 + source = "registry+https://github.com/rust-lang/crates.io-index" 1534 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 1535 + dependencies = [ 1536 + "winapi-util", 1537 + ] 1483 1538 1484 1539 [[package]] 1485 1540 name = "scopeguard" ··· 2127 2182 checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" 2128 2183 2129 2184 [[package]] 2185 + name = "unicase" 2186 + version = "2.9.0" 2187 + source = "registry+https://github.com/rust-lang/crates.io-index" 2188 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" 2189 + 2190 + [[package]] 2130 2191 name = "unicode-bidi" 2131 2192 version = "0.3.18" 2132 2193 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2212 2273 version = "0.9.5" 2213 2274 source = "registry+https://github.com/rust-lang/crates.io-index" 2214 2275 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2276 + 2277 + [[package]] 2278 + name = "walkdir" 2279 + version = "2.5.0" 2280 + source = "registry+https://github.com/rust-lang/crates.io-index" 2281 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 2282 + dependencies = [ 2283 + "same-file", 2284 + "winapi-util", 2285 + ] 2215 2286 2216 2287 [[package]] 2217 2288 name = "wasi" ··· 2330 2401 dependencies = [ 2331 2402 "libredox", 2332 2403 "wasite", 2404 + ] 2405 + 2406 + [[package]] 2407 + name = "winapi-util" 2408 + version = "0.1.11" 2409 + source = "registry+https://github.com/rust-lang/crates.io-index" 2410 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 2411 + dependencies = [ 2412 + "windows-sys 0.61.2", 2333 2413 ] 2334 2414 2335 2415 [[package]]
+2
Cargo.toml
··· 10 10 anyhow = "1" 11 11 thiserror = "2" 12 12 axum = { version = "0.8", features = ["ws"] } 13 + rust-embed = "8" 14 + mime_guess = "2" 13 15 tracing = "0.1" 14 16 tracing-subscriber = { version = "0.3", features = ["env-filter"] } 15 17 serde = { version = "1", features = ["derive"] }
+1
flake.nix
··· 56 56 cargo-audit 57 57 cargo-nextest 58 58 treefmt 59 + biome 59 60 nixfmt 60 61 yt-dlp 61 62 ffmpeg
+22 -1
src/web.rs
··· 8 8 use axum::{ 9 9 Router, 10 10 extract::{ConnectInfo, Path, Query, State, WebSocketUpgrade}, 11 - http::StatusCode, 11 + http::{StatusCode, header}, 12 12 response::{Html, IntoResponse, Json}, 13 13 routing::{get, post}, 14 14 }; 15 15 use tokio::sync::Mutex; 16 + 17 + use rust_embed::RustEmbed; 16 18 17 19 use crate::room; 18 20 use crate::types::{PlaylistEntryId, RoomId, TrackMeta}; 21 + 22 + #[derive(RustEmbed)] 23 + #[folder = "static/"] 24 + struct Assets; 19 25 20 26 /// Per-IP rate limiter for POST /api/ingest (max 1 req/5s per IP). 21 27 #[derive(Clone)] ··· 82 88 .route("/api/playlist/add", post(add_playlist_entry)) 83 89 .route("/api/playlist/remove", post(remove_playlist_entry)) 84 90 .route("/ws/{room_id}", get(ws_handler)) 91 + .route("/static/{*path}", get(static_handler)) 85 92 .with_state(state) 86 93 } 87 94 ··· 300 307 ) 301 308 .await; 302 309 })) 310 + } 311 + 312 + async fn static_handler(Path(path): Path<String>) -> impl IntoResponse { 313 + // Remove leading slash if present, default to index.html for root 314 + let path = path.strip_prefix('/').unwrap_or(&path); 315 + let path = if path.is_empty() { "index.html" } else { path }; 316 + 317 + match Assets::get(path) { 318 + Some(asset) => { 319 + let mime = mime_guess::from_path(path).first_or_octet_stream(); 320 + ([(header::CONTENT_TYPE, mime.as_ref())], asset.data.to_vec()).into_response() 321 + } 322 + None => StatusCode::NOT_FOUND.into_response(), 323 + } 303 324 } 304 325 305 326 #[cfg(test)]
+75
static/moqbox/api.js
··· 1 + // Thin HTTP API wrappers for moqbox. 2 + // No DOM. No state. Just fetch calls. 3 + 4 + export async function ingestUrl(roomId, url) { 5 + if (!url.trim()) return { error: "URL is empty" }; 6 + try { 7 + const resp = await fetch("/api/ingest", { 8 + method: "POST", 9 + headers: { "Content-Type": "application/json" }, 10 + body: JSON.stringify({ url: url.trim(), room_id: roomId }), 11 + }); 12 + if (resp.ok) return { ok: true }; 13 + const err = await resp.json().catch(() => ({ error: "request failed" })); 14 + return { error: err.error || "ingest failed" }; 15 + } catch (e) { 16 + return { error: "network error" }; 17 + } 18 + } 19 + 20 + export async function skip(roomId) { 21 + try { 22 + await fetch("/api/skip", { 23 + method: "POST", 24 + headers: { "Content-Type": "application/json" }, 25 + body: JSON.stringify({ room_id: roomId }), 26 + }); 27 + } catch { 28 + // skip is best-effort 29 + } 30 + } 31 + 32 + export async function addPlaylistEntry(roomId, url) { 33 + if (!url.trim()) return { error: "URL is empty" }; 34 + try { 35 + const resp = await fetch("/api/playlist/add", { 36 + method: "POST", 37 + headers: { "Content-Type": "application/json" }, 38 + body: JSON.stringify({ url: url.trim(), room_id: roomId }), 39 + }); 40 + if (resp.ok) return { ok: true }; 41 + const err = await resp.json().catch(() => ({ error: "request failed" })); 42 + return { error: err.error || "add to playlist failed" }; 43 + } catch (e) { 44 + return { error: "network error" }; 45 + } 46 + } 47 + 48 + export async function removePlaylistEntry(roomId, id) { 49 + try { 50 + await fetch("/api/playlist/remove", { 51 + method: "POST", 52 + headers: { "Content-Type": "application/json" }, 53 + body: JSON.stringify({ room_id: roomId, id }), 54 + }); 55 + } catch { 56 + // best-effort 57 + } 58 + } 59 + 60 + export async function copyRoomLink() { 61 + const url = window.location.href; 62 + try { 63 + await navigator.clipboard.writeText(url); 64 + return true; 65 + } catch { 66 + // Fallback for older browsers 67 + const input = document.createElement("input"); 68 + input.value = url; 69 + document.body.appendChild(input); 70 + input.select(); 71 + document.execCommand("copy"); 72 + document.body.removeChild(input); 73 + return true; 74 + } 75 + }
+273
static/moqbox/main.js
··· 1 + import { createWsClient } from "./ws-client.js"; 2 + import { initialState, transition } from "./state.js"; 3 + import { createVideoPipeline } from "./video-pipeline.js"; 4 + import { 5 + renderAll, 6 + renderChatMessage, 7 + renderConnectionStatus, 8 + renderElapsed, 9 + renderProgress, 10 + } from "./ui.js"; 11 + import { 12 + ingestUrl, 13 + skip as apiSkip, 14 + addPlaylistEntry, 15 + removePlaylistEntry, 16 + copyRoomLink, 17 + } from "./api.js"; 18 + import { TRACK_VIDEO, TRACK_CHAT, TRACK_STATE } from "./protocol.js"; 19 + 20 + const DEBUG = 21 + new URLSearchParams(location.search).has("debug") || 22 + localStorage.getItem("moqbox_debug"); 23 + function debug(...args) { 24 + if (DEBUG) console.log(...args); 25 + } 26 + 27 + const roomId = location.pathname.split("/").pop(); 28 + window.__moqboxRoomId = roomId; 29 + 30 + let userName = sessionStorage.getItem("moqbox_user"); 31 + if (!userName) { 32 + userName = prompt("Enter your name:", "anonymous") || "anonymous"; 33 + sessionStorage.setItem("moqbox_user", userName); 34 + } 35 + 36 + let state = initialState(); 37 + let pipeline = null; 38 + let elapsedInterval = null; 39 + let trackOverlayTimeout = null; 40 + 41 + const containerEl = 42 + document.getElementById("video-container") || 43 + document.querySelector(".main-col"); 44 + 45 + function initPipeline() { 46 + if (pipeline) return; 47 + pipeline = createVideoPipeline(containerEl); 48 + 49 + const volSlider = document.getElementById("vol-slider"); 50 + const volIcon = document.getElementById("vol-icon"); 51 + volSlider.addEventListener("input", () => { 52 + const v = parseFloat(volSlider.value); 53 + pipeline.videoEl.volume = v; 54 + pipeline.videoEl.muted = v === 0; 55 + volIcon.textContent = 56 + v === 0 ? "\u{1F507}" : v < 0.5 ? "\u{1F509}" : "\u{1F50A}"; 57 + }); 58 + volIcon.addEventListener("click", () => { 59 + if (pipeline.videoEl.muted) { 60 + pipeline.videoEl.muted = false; 61 + pipeline.videoEl.volume = 1; 62 + volSlider.value = "1"; 63 + volIcon.textContent = "\u{1F50A}"; 64 + } else { 65 + pipeline.videoEl.muted = true; 66 + volSlider.value = "0"; 67 + volIcon.textContent = "\u{1F507}"; 68 + } 69 + }); 70 + } 71 + 72 + function isInitSegment(data) { 73 + return ( 74 + data.length >= 8 && 75 + String.fromCharCode(data[4], data[5], data[6], data[7]) === "ftyp" 76 + ); 77 + } 78 + 79 + function dispatchEffects(effects) { 80 + for (const effect of effects) { 81 + switch (effect.type) { 82 + case "reset-media-pipeline": 83 + if (pipeline) pipeline.reset(); 84 + break; 85 + case "append-box": 86 + if (!pipeline) initPipeline(); 87 + pipeline.append(effect.data); 88 + break; 89 + case "start-elapsed-timer": 90 + if (!elapsedInterval && state.startedAt) { 91 + elapsedInterval = setInterval(tickTimer, 1000); 92 + } 93 + break; 94 + case "stop-elapsed-timer": 95 + if (elapsedInterval) { 96 + clearInterval(elapsedInterval); 97 + elapsedInterval = null; 98 + } 99 + break; 100 + case "seek-to-live": 101 + if (pipeline && state.startedAt) { 102 + pipeline.setCurrentTime((Date.now() - state.startedAt) / 1000); 103 + } 104 + break; 105 + case "render-ui": 106 + renderAll(state); 107 + break; 108 + } 109 + } 110 + } 111 + 112 + function tickTimer() { 113 + if (!state.startedAt) return; 114 + const elapsed = (Date.now() - state.startedAt) / 1000; 115 + renderElapsed(elapsed); 116 + if (state.currentTrack) { 117 + const total = parseDuration(state.currentTrack.duration); 118 + if (total > 0) { 119 + renderProgress(Math.min(1, elapsed / total)); 120 + } 121 + } 122 + if (pipeline && state.startedAt) { 123 + const target = (Date.now() - state.startedAt) / 1000; 124 + if (target > 0 && target - pipeline.videoEl.currentTime > 3) { 125 + pipeline.setCurrentTime(target); 126 + } 127 + } 128 + } 129 + 130 + function parseDuration(s) { 131 + const parts = s.split(":").map(Number); 132 + if (parts.length === 2) return parts[0] * 60 + parts[1]; 133 + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; 134 + return 0; 135 + } 136 + 137 + function applyTransition(event) { 138 + const result = transition(state, event); 139 + state = result.state; 140 + dispatchEffects(result.effects); 141 + } 142 + 143 + function onWsMessage(msg) { 144 + if (msg.type === "object") { 145 + if (msg.trackId === TRACK_CHAT) { 146 + try { 147 + const chat = JSON.parse(new TextDecoder().decode(msg.payload)); 148 + renderChatMessage(chat); 149 + } catch (_) {} 150 + } else if (msg.trackId === TRACK_STATE) { 151 + try { 152 + const payload = JSON.parse(new TextDecoder().decode(msg.payload)); 153 + applyTransition({ type: "state-snapshot", payload }); 154 + // Show overlay briefly on track change 155 + const toOverlay = document.getElementById("track-overlay"); 156 + if (toOverlay && state.videoPhase === "awaiting-init") { 157 + toOverlay.classList.add("show"); 158 + if (trackOverlayTimeout) clearTimeout(trackOverlayTimeout); 159 + trackOverlayTimeout = setTimeout( 160 + () => toOverlay.classList.remove("show"), 161 + 3000, 162 + ); 163 + } 164 + } catch (_) {} 165 + } else if (msg.trackId === TRACK_VIDEO) { 166 + const data = new Uint8Array(msg.payload); 167 + if (isInitSegment(data)) { 168 + applyTransition({ type: "video-init-segment", groupId: msg.groupId }); 169 + if (!pipeline) initPipeline(); 170 + pipeline.append(data); 171 + } else { 172 + applyTransition({ type: "video-moof", groupId: msg.groupId, data }); 173 + } 174 + } 175 + } 176 + } 177 + 178 + function onWsStateChange(connected) { 179 + renderConnectionStatus(connected); 180 + } 181 + 182 + initPipeline(); 183 + const ws = createWsClient({ 184 + roomId, 185 + userName, 186 + onMessage: onWsMessage, 187 + onStateChange: onWsStateChange, 188 + debug: DEBUG, 189 + }); 190 + 191 + function sendChat() { 192 + const input = document.getElementById("chat-input"); 193 + const content = input.value.trim(); 194 + if (!content) return; 195 + const payload = new TextEncoder().encode(JSON.stringify({ content })); 196 + ws.sendObject(2, 0, 0, payload); 197 + input.value = ""; 198 + } 199 + 200 + document.addEventListener("click", (e) => { 201 + const btn = e.target.closest(".rot-add, .playlist-remove"); 202 + if (!btn) return; 203 + const url = btn.dataset.trackUrl; 204 + const id = btn.dataset.trackId; 205 + if (btn.classList.contains("rot-add") && url) { 206 + e.preventDefault(); 207 + addPlaylistEntry(roomId, url); 208 + } else if (btn.classList.contains("playlist-remove") && id) { 209 + e.preventDefault(); 210 + removePlaylistEntry(roomId, id); 211 + } 212 + }); 213 + 214 + document.getElementById("chat-send")?.addEventListener("click", sendChat); 215 + document.getElementById("chat-input")?.addEventListener("keydown", (e) => { 216 + if (e.key === "Enter") sendChat(); 217 + }); 218 + document.getElementById("playlist-input")?.addEventListener("keydown", (e) => { 219 + if (e.key === "Enter") { 220 + const input = document.getElementById("playlist-input"); 221 + addToPlaylistUI(); 222 + } 223 + }); 224 + document.getElementById("url-input")?.addEventListener("keydown", (e) => { 225 + if (e.key === "Enter") ingestUI(); 226 + }); 227 + 228 + document.getElementById("queue-add-btn")?.addEventListener("click", ingestUI); 229 + document 230 + .getElementById("skip-btn") 231 + ?.addEventListener("click", () => apiSkip(roomId)); 232 + document 233 + .getElementById("playlist-add-btn") 234 + ?.addEventListener("click", addToPlaylistUI); 235 + document.getElementById("copy-link")?.addEventListener("click", copyRoomLink); 236 + 237 + async function ingestUI() { 238 + const input = document.getElementById("url-input"); 239 + const btn = document.getElementById("queue-add-btn"); 240 + if (!input.value.trim()) return; 241 + btn.disabled = true; 242 + btn.textContent = "Adding…"; 243 + try { 244 + const result = await ingestUrl(roomId, input.value); 245 + if (result.ok) { 246 + input.value = ""; 247 + } else if (result.error) { 248 + alert(result.error); 249 + } 250 + } finally { 251 + btn.disabled = false; 252 + btn.textContent = "Add"; 253 + } 254 + } 255 + 256 + async function addToPlaylistUI() { 257 + const input = document.getElementById("playlist-input"); 258 + const btn = document.querySelector("#playlist-section .btn-primary"); 259 + if (!input.value.trim()) return; 260 + btn.disabled = true; 261 + btn.textContent = "Adding…"; 262 + try { 263 + const result = await addPlaylistEntry(roomId, input.value); 264 + if (result.ok) { 265 + input.value = ""; 266 + } else if (result.error) { 267 + alert(result.error); 268 + } 269 + } finally { 270 + btn.disabled = false; 271 + btn.textContent = "Add"; 272 + } 273 + }
+151
static/moqbox/protocol.js
··· 1 + // MoQ WebSocket wire format: varint encoding/decoding and message framing. 2 + // 3 + // All functions are pure — no IO, no DOM, no side effects. 4 + // Testable by feeding bytes, asserting outputs. 5 + 6 + // --- Track ID constants --- 7 + export const TRACK_VIDEO = 1; 8 + export const TRACK_CHAT = 2; 9 + export const TRACK_STATE = 3; 10 + 11 + // --- Encode --- 12 + 13 + export function encVarint(n) { 14 + if (n < 64) return new Uint8Array([n]); 15 + if (n < 16384) return new Uint8Array([(n >> 8) | 0x40, n & 0xff]); 16 + if (n < 1073741824) 17 + return new Uint8Array([ 18 + (n >> 24) | 0x80, 19 + (n >> 16) & 0xff, 20 + (n >> 8) & 0xff, 21 + n & 0xff, 22 + ]); 23 + const b = new Uint8Array(8); 24 + b[0] = (n >> 56) | 0xc0; 25 + b[1] = (n >> 48) & 0xff; 26 + b[2] = (n >> 40) & 0xff; 27 + b[3] = (n >> 32) & 0xff; 28 + b[4] = (n >> 24) & 0xff; 29 + b[5] = (n >> 16) & 0xff; 30 + b[6] = (n >> 8) & 0xff; 31 + b[7] = n & 0xff; 32 + return b; 33 + } 34 + 35 + export function encString(s) { 36 + const bytes = new TextEncoder().encode(s); 37 + const len = encVarint(bytes.length); 38 + const out = new Uint8Array(len.length + bytes.length); 39 + out.set(len); 40 + out.set(bytes, len.length); 41 + return out; 42 + } 43 + 44 + export function concat(arrays) { 45 + const total = arrays.reduce((sum, arr) => sum + arr.length, 0); 46 + const out = new Uint8Array(total); 47 + let off = 0; 48 + for (const a of arrays) { 49 + out.set(a, off); 50 + off += a.length; 51 + } 52 + return out; 53 + } 54 + 55 + // --- Decode --- 56 + 57 + export function decVarint(buf, off) { 58 + const first = buf[off]; 59 + const tag = first >> 6; 60 + if (tag === 0) return [first & 0x3f, off + 1]; 61 + if (tag === 1) return [((first & 0x3f) << 8) | buf[off + 1], off + 2]; 62 + if (tag === 2) 63 + return [ 64 + ((first & 0x3f) << 24) | 65 + (buf[off + 1] << 16) | 66 + (buf[off + 2] << 8) | 67 + buf[off + 3], 68 + off + 4, 69 + ]; 70 + let val = BigInt(first & 0x3f) << 56n; 71 + for (let i = 1; i < 8; i++) 72 + val |= BigInt(buf[off + i]) << BigInt(8 * (7 - i)); 73 + return [Number(val), off + 8]; 74 + } 75 + 76 + export function decString(buf, off) { 77 + const [len, o] = decVarint(buf, off); 78 + const str = new TextDecoder().decode(buf.slice(o, o + len)); 79 + return [str, o + len]; 80 + } 81 + 82 + // --- Message parsing --- 83 + 84 + export const MSG_OBJECT = 0x00; 85 + export const MSG_SUBSCRIBE = 0x01; 86 + export const MSG_SUBSCRIBE_OK = 0x02; 87 + export const MSG_ANNOUNCE = 0x03; 88 + export const MSG_ANNOUNCE_OK = 0x04; 89 + export const MSG_SUBSCRIBE_RST = 0x06; 90 + 91 + /** 92 + * Parse a binary MoQ frame buffer into an array of structured messages. 93 + * 94 + * A single WebSocket message may contain multiple MoQ messages. 95 + * Returns an array of { type, ...fields } objects. 96 + */ 97 + export function parseMessages(buf) { 98 + const messages = []; 99 + let off = 0; 100 + while (off < buf.length) { 101 + const [msgType, o1] = decVarint(buf, off); 102 + if (msgType === MSG_OBJECT) { 103 + const [trackId, o2] = decVarint(buf, o1); 104 + const [groupId, o3] = decVarint(buf, o2); 105 + const [objectId, o4] = decVarint(buf, o3); 106 + const payload = buf.slice(o4); 107 + messages.push({ type: "object", trackId, groupId, objectId, payload }); 108 + off = buf.length; // single object per WS message in practice 109 + } else if (msgType === MSG_SUBSCRIBE_OK) { 110 + const [ns, o2] = decString(buf, o1); 111 + const [tr, o3] = decString(buf, o2); 112 + messages.push({ type: "subscribeOk", namespace: ns, trackName: tr }); 113 + off = o3; 114 + } else if (msgType === MSG_SUBSCRIBE_RST) { 115 + const [ns, o2] = decString(buf, o1); 116 + const [tr, o3] = decString(buf, o2); 117 + const [reason, o4] = decString(buf, o3); 118 + messages.push({ 119 + type: "subscribeRst", 120 + namespace: ns, 121 + trackName: tr, 122 + reason, 123 + }); 124 + off = o4; 125 + } else { 126 + // Unknown message type — stop parsing 127 + break; 128 + } 129 + } 130 + return messages; 131 + } 132 + 133 + // --- Frame builders --- 134 + 135 + export function buildSubscribe(namespace, trackName) { 136 + return concat([ 137 + new Uint8Array([MSG_SUBSCRIBE]), 138 + encString(namespace), 139 + encString(trackName), 140 + ]); 141 + } 142 + 143 + export function buildObjectFrame(trackId, groupId, objectId, payload) { 144 + return concat([ 145 + new Uint8Array([MSG_OBJECT]), 146 + encVarint(trackId), 147 + encVarint(groupId), 148 + encVarint(objectId), 149 + payload, 150 + ]); 151 + }
+161
static/moqbox/state.js
··· 1 + export function initialState() { 2 + return { 3 + currentTrack: null, 4 + queue: [], 5 + history: [], 6 + playlist: [], 7 + roomName: "", 8 + viewerCount: 0, 9 + 10 + videoPhase: "idle", 11 + processedGroupId: -1, 12 + currentGroupId: -1, 13 + currentTrackId: null, 14 + 15 + startedAt: 0, 16 + seekInitDone: false, 17 + }; 18 + } 19 + 20 + export function transition(state, event) { 21 + switch (event.type) { 22 + case "state-snapshot": 23 + return onStateSnapshot(state, event.payload); 24 + case "video-init-segment": 25 + return onVideoInit(state, event.groupId); 26 + case "video-moof": 27 + return onVideoMoof(state, event.groupId, event.data); 28 + case "track-ended-natural": 29 + return onTrackEnded(state); 30 + default: 31 + return { state, effects: [] }; 32 + } 33 + } 34 + 35 + // --- Event handlers --- 36 + 37 + function onStateSnapshot(state, payload) { 38 + const effects = []; 39 + 40 + if (!payload.current_track) { 41 + const next = { ...state, currentTrack: null, videoPhase: "idle" }; 42 + if (state.videoPhase !== "idle") { 43 + effects.push({ type: "reset-media-pipeline" }); 44 + effects.push({ type: "stop-elapsed-timer" }); 45 + effects.push({ type: "render-ui" }); 46 + } 47 + return mergeSnapshot(next, payload, effects); 48 + } 49 + 50 + const track = payload.current_track; 51 + const trackChanged = state.currentTrackId !== track.id; 52 + const newGroupId = track.group_id ?? -1; 53 + const startedAt = track.started_at || 0; 54 + 55 + if (trackChanged) { 56 + if (state.processedGroupId < newGroupId) { 57 + const next = { 58 + ...state, 59 + currentTrackId: track.id, 60 + currentGroupId: newGroupId, 61 + startedAt, 62 + videoPhase: "awaiting-init", 63 + seekInitDone: false, 64 + }; 65 + effects.push({ type: "reset-media-pipeline" }); 66 + effects.push({ type: "stop-elapsed-timer" }); 67 + effects.push({ type: "render-ui" }); 68 + return mergeSnapshot(next, payload, effects); 69 + } else { 70 + const next = { 71 + ...state, 72 + currentTrackId: track.id, 73 + currentGroupId: newGroupId, 74 + startedAt, 75 + videoPhase: "playing", 76 + seekInitDone: false, 77 + }; 78 + effects.push({ type: "stop-elapsed-timer" }); 79 + effects.push({ type: "start-elapsed-timer" }); 80 + effects.push({ type: "render-ui" }); 81 + return mergeSnapshot(next, payload, effects); 82 + } 83 + } 84 + 85 + const next = { 86 + ...state, 87 + currentGroupId: newGroupId, 88 + startedAt, 89 + }; 90 + if (!state.startedAt && startedAt) { 91 + effects.push({ type: "start-elapsed-timer" }); 92 + } 93 + effects.push({ type: "render-ui" }); 94 + return mergeSnapshot(next, payload, effects); 95 + } 96 + 97 + function onVideoInit(state, groupId) { 98 + // Discard stale init from old pipeline 99 + if (groupId < state.processedGroupId) { 100 + return { state, effects: [] }; 101 + } 102 + 103 + const next = { 104 + ...state, 105 + processedGroupId: groupId, 106 + videoPhase: "playing", 107 + seekInitDone: false, 108 + }; 109 + return { 110 + state: next, 111 + effects: [ 112 + { type: "reset-media-pipeline" }, 113 + { type: "seek-to-live" }, 114 + { type: "render-ui" }, 115 + ], 116 + }; 117 + } 118 + 119 + function onVideoMoof(state, groupId, data) { 120 + if (groupId < state.processedGroupId) { 121 + return { state, effects: [] }; 122 + } 123 + return { state, effects: [{ type: "append-box", data }] }; 124 + } 125 + 126 + function onTrackEnded(state) { 127 + if (state.videoPhase === "idle") return { state, effects: [] }; 128 + return { 129 + state: { ...state, videoPhase: "idle" }, 130 + effects: [ 131 + { type: "reset-media-pipeline" }, 132 + { type: "stop-elapsed-timer" }, 133 + { type: "render-ui" }, 134 + ], 135 + }; 136 + } 137 + 138 + function mergeSnapshot(state, payload, effects) { 139 + return { 140 + state: { 141 + ...state, 142 + currentTrack: payload.current_track 143 + ? { 144 + id: payload.current_track.id, 145 + title: payload.current_track.title, 146 + url: payload.current_track.url || "", 147 + duration: payload.current_track.duration, 148 + thumbnail: payload.current_track.thumbnail || null, 149 + startedAt: payload.current_track.started_at || 0, 150 + groupId: payload.current_track.group_id ?? -1, 151 + } 152 + : null, 153 + queue: payload.queue || [], 154 + history: payload.history || [], 155 + playlist: payload.playlist || [], 156 + roomName: payload.room_name || state.roomName, 157 + viewerCount: payload.clients ?? state.viewerCount, 158 + }, 159 + effects, 160 + }; 161 + }
+283
static/moqbox/ui.js
··· 1 + // DOM rendering for moqbox. 2 + // No protocol, state, or WS logic — pure DOM manipulation. 3 + 4 + export function formatTime(secs) { 5 + const m = Math.floor(secs / 60); 6 + const s = Math.floor(secs % 60); 7 + return m + ":" + String(s).padStart(2, "0"); 8 + } 9 + 10 + export function esc(s) { 11 + const d = document.createElement("div"); 12 + d.textContent = s; 13 + return d.innerHTML; 14 + } 15 + 16 + export function safeUrl(url) { 17 + if (!url) return ""; 18 + if (url.startsWith("http://") || url.startsWith("https://")) { 19 + return esc(url); 20 + } 21 + return ""; 22 + } 23 + 24 + function parseDuration(s) { 25 + const parts = s.split(":").map(Number); 26 + if (parts.length === 2) return parts[0] * 60 + parts[1]; 27 + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; 28 + return 0; 29 + } 30 + 31 + function renderTrackItem(track, actions, extraCls) { 32 + const cls = extraCls || ""; 33 + const thumb = track.thumbnail 34 + ? '<img src="' + esc(track.thumbnail) + '" class="thumb-img" alt="">' 35 + : '<div class="thumb"></div>'; 36 + let btns = ""; 37 + for (const a of actions) { 38 + if (a.cls === "source-link") { 39 + btns += 40 + '<a href="' + 41 + safeUrl(track.url) + 42 + '" target="_blank" class="source-link" rel="noopener noreferrer">source</a>'; 43 + } else { 44 + btns += 45 + '<button class="' + 46 + a.cls + 47 + '" data-track-url="' + 48 + esc(String(track.url || "")) + 49 + '" data-track-id="' + 50 + (track.id ?? "") + 51 + '" title="' + 52 + a.title + 53 + '">' + 54 + a.label + 55 + "</button>"; 56 + } 57 + } 58 + return ( 59 + '<li class="' + 60 + cls + 61 + '">' + 62 + thumb + 63 + '<div class="title">' + 64 + esc(track.title || "Loading...") + 65 + "</div>" + 66 + '<div class="duration">' + 67 + esc(track.duration || "--:--") + 68 + "</div>" + 69 + btns + 70 + "</li>" 71 + ); 72 + } 73 + 74 + export function renderAll(state) { 75 + const container = document.getElementById("video-container"); 76 + if (!container) return; 77 + 78 + if (state.roomName) { 79 + const nameEl = document.getElementById("room-name"); 80 + if (nameEl) nameEl.textContent = state.roomName; 81 + } 82 + 83 + const pill = document.getElementById("viewer-pill"); 84 + if (pill) { 85 + const c = state.viewerCount; 86 + pill.textContent = c === 1 ? "1 viewer" : c + " viewers"; 87 + } 88 + 89 + if (state.currentTrack) { 90 + renderNowPlaying(state.currentTrack, state.viewerCount); 91 + } else { 92 + container.classList.add("idle"); 93 + } 94 + 95 + renderQueue(state.queue, state.currentTrack !== null); 96 + renderPlaylist(state.playlist); 97 + renderHistory(state.history); 98 + } 99 + 100 + function renderNowPlaying(track, viewerCount) { 101 + const container = document.getElementById("video-container"); 102 + if (!container) return; 103 + container.classList.remove("idle"); 104 + 105 + const toThumb = document.getElementById("to-thumb"); 106 + const toTitle = document.getElementById("to-title"); 107 + const toElapsed = document.getElementById("to-elapsed"); 108 + const toDuration = document.getElementById("to-duration"); 109 + const toSource = document.getElementById("to-source"); 110 + const toViewers = document.getElementById("to-viewers"); 111 + const toRot = document.getElementById("to-rot"); 112 + 113 + if (track.thumbnail) { 114 + toThumb.src = track.thumbnail; 115 + toThumb.style.display = "block"; 116 + } else { 117 + toThumb.style.display = "none"; 118 + } 119 + 120 + toTitle.textContent = track.title; 121 + toDuration.textContent = " / " + track.duration; 122 + 123 + if (track.url) { 124 + toSource.href = track.url; 125 + toSource.textContent = "source"; 126 + toSource.style.display = "inline"; 127 + if (toRot) { 128 + toRot.style.display = "inline-flex"; 129 + toRot.onclick = () => { 130 + import("./api.js").then((api) => { 131 + const roomId = window.__moqboxRoomId; 132 + if (roomId && track.url) api.addPlaylistEntry(roomId, track.url); 133 + }); 134 + }; 135 + } 136 + } else { 137 + toSource.style.display = "none"; 138 + if (toRot) toRot.style.display = "none"; 139 + } 140 + 141 + toViewers.textContent = viewerCount 142 + ? " · " + viewerCount + " viewer" + (viewerCount !== 1 ? "s" : "") 143 + : ""; 144 + 145 + toElapsed.textContent = track.startedAt 146 + ? formatTime((Date.now() - track.startedAt) / 1000) 147 + : "0:00"; 148 + } 149 + 150 + export function renderChatMessage(chat) { 151 + const container = document.getElementById("chat-messages"); 152 + if (!container) return; 153 + 154 + const div = document.createElement("div"); 155 + div.className = "chat-msg" + (chat.type === "system_event" ? " system" : ""); 156 + 157 + let ts = ""; 158 + if (chat.ts) { 159 + const d = new Date(chat.ts); 160 + ts = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 161 + } 162 + 163 + div.innerHTML = 164 + '<span class="user">' + 165 + esc(chat.user || "anonymous") + 166 + "</span>" + 167 + '<span class="time">' + 168 + ts + 169 + "</span><br>" + 170 + '<span class="text">' + 171 + esc(chat.content || "") + 172 + "</span>"; 173 + 174 + container.appendChild(div); 175 + 176 + // Auto-scroll only when user is near the bottom 177 + const threshold = 30; 178 + if ( 179 + container.scrollHeight - container.scrollTop - container.clientHeight < 180 + threshold 181 + ) { 182 + container.scrollTop = container.scrollHeight; 183 + } 184 + } 185 + 186 + export function renderConnectionStatus(connected) { 187 + const el = document.getElementById("conn-status"); 188 + if (!el) return; 189 + el.className = "conn-status " + (connected ? "connected" : "disconnected"); 190 + el.textContent = connected ? "connected" : "disconnected"; 191 + } 192 + 193 + export function renderElapsed(seconds) { 194 + const el = document.getElementById("to-elapsed"); 195 + if (el) el.textContent = formatTime(seconds); 196 + } 197 + 198 + export function renderProgress(fraction) { 199 + const fill = document.getElementById("to-progress-fill"); 200 + if (fill) fill.style.width = Math.min(100, fraction * 100) + "%"; 201 + } 202 + 203 + function renderQueue(queue, hasCurrentTrack) { 204 + const qSection = document.getElementById("queue-section"); 205 + if (!qSection) return; 206 + 207 + const rotAction = { cls: "rot-add", label: "+", title: "Add to Rotation" }; 208 + const linkAction = { 209 + cls: "source-link", 210 + label: "source", 211 + title: "Open source", 212 + }; 213 + 214 + if (queue && queue.length > 0) { 215 + let html = '<ul class="queue">'; 216 + for (const item of queue) { 217 + html += renderTrackItem( 218 + item, 219 + [linkAction, rotAction], 220 + item.pending ? "pending" : "", 221 + ); 222 + } 223 + html += "</ul>"; 224 + qSection.innerHTML = html; 225 + } else if (hasCurrentTrack) { 226 + qSection.innerHTML = 227 + '<div class="empty-state"><div class="icon">&#x25B6;</div><div class="label">No upcoming tracks</div><div class="hint">Add another link to keep the queue going.</div></div>'; 228 + } else { 229 + qSection.innerHTML = 230 + '<div class="empty-state"><div class="icon">&#x1F3B5;</div><div class="label">Queue is empty</div><div class="hint">Paste a link from YouTube, SoundCloud, or Bandcamp above to get started.</div></div>'; 231 + } 232 + } 233 + 234 + function renderHistory(history) { 235 + const hSection = document.getElementById("history-section"); 236 + if (!hSection) return; 237 + 238 + const linkAction = { 239 + cls: "source-link", 240 + label: "source", 241 + title: "Open source", 242 + }; 243 + const rotAction = { cls: "rot-add", label: "+", title: "Add to Rotation" }; 244 + 245 + if (history && history.length > 0) { 246 + let html = '<div class="history-header">Played</div><ul class="queue">'; 247 + for (const item of history) { 248 + html += renderTrackItem(item, [linkAction, rotAction], "history-item"); 249 + } 250 + html += "</ul>"; 251 + hSection.innerHTML = html; 252 + } else { 253 + hSection.innerHTML = ""; 254 + } 255 + } 256 + 257 + function renderPlaylist(playlist) { 258 + const container = document.getElementById("playlist-items"); 259 + if (!container) return; 260 + 261 + if (!playlist || playlist.length === 0) { 262 + container.innerHTML = '<div class="playlist-empty">Rotation is empty</div>'; 263 + return; 264 + } 265 + 266 + const removeAction = { 267 + cls: "playlist-remove", 268 + label: "\u2715", 269 + title: "Remove from Rotation", 270 + }; 271 + const linkAction = { 272 + cls: "source-link", 273 + label: "source", 274 + title: "Open source", 275 + }; 276 + 277 + let html = '<ul class="queue">'; 278 + for (const entry of playlist) { 279 + html += renderTrackItem(entry, [linkAction, removeAction]); 280 + } 281 + html += "</ul>"; 282 + container.innerHTML = html; 283 + }
+155
static/moqbox/video-pipeline.js
··· 1 + // MediaSource/SourceBuffer management. Owns the <video> element and pending box queue. 2 + 3 + const VIDEO_CODEC = 'video/mp4; codecs="avc1.4d0028,mp4a.40.2"'; 4 + 5 + export function createVideoPipeline(containerEl) { 6 + if (document.getElementById("video-player")) { 7 + return createExistingPipeline(document.getElementById("video-player")); 8 + } 9 + 10 + const video = document.createElement("video"); 11 + video.id = "video-player"; 12 + video.controls = false; 13 + video.autoplay = true; 14 + video.muted = true; 15 + containerEl.insertBefore(video, containerEl.firstChild); 16 + 17 + let mediaSource = null; 18 + let sourceBuffer = null; 19 + const pendingBoxes = []; 20 + let sourceOpen = false; 21 + let destroyed = false; 22 + let seekInitDone = false; 23 + 24 + function flushPending() { 25 + while (pendingBoxes.length > 0) { 26 + const box = pendingBoxes[0]; 27 + if (sourceBuffer && !sourceBuffer.updating) { 28 + try { 29 + sourceBuffer.appendBuffer(box); 30 + pendingBoxes.shift(); 31 + } catch (e) { 32 + console.warn("SourceBuffer append failed:", e); 33 + pendingBoxes.shift(); 34 + } 35 + } else { 36 + break; 37 + } 38 + } 39 + } 40 + 41 + function initMediaSource() { 42 + try { 43 + mediaSource = new MediaSource(); 44 + mediaSource.addEventListener("sourceopen", () => { 45 + if (destroyed) return; 46 + sourceOpen = true; 47 + try { 48 + sourceBuffer = mediaSource.addSourceBuffer(VIDEO_CODEC); 49 + sourceBuffer.mode = "segments"; 50 + sourceBuffer.addEventListener("updateend", () => { 51 + flushPending(); 52 + if (!destroyed) { 53 + const v = document.getElementById("video-player"); 54 + if (v && v.buffered.length > 0 && !seekInitDone) { 55 + seekInitDone = true; 56 + v.currentTime = v.buffered.start(0); 57 + } 58 + } 59 + }); 60 + flushPending(); 61 + } catch (e) { 62 + console.warn("MediaSource not supported:", e); 63 + } 64 + }); 65 + video.src = URL.createObjectURL(mediaSource); 66 + } catch (e) { 67 + console.warn("MediaSource not supported:", e); 68 + } 69 + } 70 + 71 + function append(data) { 72 + if (destroyed) return; 73 + if (sourceBuffer && sourceOpen && !sourceBuffer.updating) { 74 + try { 75 + sourceBuffer.appendBuffer(data); 76 + } catch (e) { 77 + console.warn("SourceBuffer append failed:", e); 78 + } 79 + } else { 80 + pendingBoxes.push(data); 81 + } 82 + } 83 + 84 + function reset() { 85 + seekInitDone = false; 86 + pendingBoxes.length = 0; 87 + if (sourceBuffer && sourceOpen && !sourceBuffer.updating) { 88 + try { 89 + sourceBuffer.abort(); 90 + if (sourceBuffer.buffered.length > 0) { 91 + const end = sourceBuffer.buffered.end( 92 + sourceBuffer.buffered.length - 1, 93 + ); 94 + sourceBuffer.remove(0, end); 95 + } 96 + } catch (_) {} 97 + } 98 + } 99 + 100 + function setCurrentTime(time) { 101 + const v = document.getElementById("video-player"); 102 + if (v) { 103 + try { 104 + v.currentTime = time; 105 + } catch (_) {} 106 + } 107 + } 108 + 109 + function destroy() { 110 + destroyed = true; 111 + pendingBoxes.length = 0; 112 + if (mediaSource) { 113 + try { 114 + if (mediaSource.readyState === "open") mediaSource.endOfStream(); 115 + } catch (_) {} 116 + } 117 + if (video && video.parentNode) { 118 + video.parentNode.removeChild(video); 119 + } 120 + mediaSource = null; 121 + sourceBuffer = null; 122 + } 123 + 124 + initMediaSource(); 125 + 126 + return { 127 + reset, 128 + append, 129 + setCurrentTime, 130 + destroy, 131 + videoEl: video, 132 + get ready() { 133 + return sourceOpen && sourceBuffer !== null; 134 + }, 135 + }; 136 + } 137 + 138 + function createExistingPipeline(videoEl) { 139 + return { 140 + reset() {}, 141 + append() { 142 + console.warn("createVideoPipeline called twice"); 143 + }, 144 + setCurrentTime(time) { 145 + try { 146 + videoEl.currentTime = time; 147 + } catch (_) {} 148 + }, 149 + destroy() {}, 150 + videoEl, 151 + get ready() { 152 + return false; 153 + }, 154 + }; 155 + }
+110
static/moqbox/ws-client.js
··· 1 + // WebSocket lifecycle for moqbox. 2 + // Handles connect, reconnect, message dispatch, and subscription. 3 + 4 + import { 5 + parseMessages, 6 + buildSubscribe, 7 + buildObjectFrame, 8 + concat, 9 + } from "./protocol.js"; 10 + 11 + const MAX_RECONNECT_DELAY = 30000; 12 + 13 + /** 14 + * Create a WebSocket client for a moqbox room. 15 + * 16 + * @param {object} config 17 + * @param {string} config.roomId 18 + * @param {string} config.userName 19 + * @param {(msg: import("./protocol.js").MoqMessage) => void} config.onMessage 20 + * @param {(connected: boolean) => void} config.onStateChange 21 + * @param {boolean} [config.debug] 22 + * @returns {{ close: () => void }} 23 + */ 24 + export function createWsClient(config) { 25 + const { roomId, userName, onMessage, onStateChange } = config; 26 + const debug = config.debug || false; 27 + 28 + let ws = null; 29 + let reconnectTimer = null; 30 + let reconnectDelay = 1000; 31 + let closed = false; 32 + 33 + const proto = location.protocol === "https:" ? "wss:" : "ws:"; 34 + const url = 35 + proto + 36 + "//" + 37 + location.host + 38 + "/ws/" + 39 + roomId + 40 + "?name=" + 41 + encodeURIComponent(userName); 42 + 43 + function log(...args) { 44 + if (debug) console.log("[ws]", ...args); 45 + } 46 + 47 + function subscribeTracks() { 48 + if (!ws) return; 49 + ws.send(buildSubscribe("moqbox/room/" + roomId + "/video", "video")); 50 + ws.send(buildSubscribe("moqbox/room/" + roomId + "/chat", "chat")); 51 + ws.send(buildSubscribe("moqbox/room/" + roomId + "/state", "state")); 52 + } 53 + 54 + function connect() { 55 + if (closed) return; 56 + log("connecting..."); 57 + ws = new WebSocket(url); 58 + ws.binaryType = "arraybuffer"; 59 + 60 + ws.onopen = () => { 61 + log("connected"); 62 + reconnectDelay = 1000; 63 + onStateChange(true); 64 + subscribeTracks(); 65 + }; 66 + 67 + ws.onmessage = (event) => { 68 + const buf = new Uint8Array(event.data); 69 + const messages = parseMessages(buf); 70 + for (const msg of messages) { 71 + onMessage(msg); 72 + } 73 + }; 74 + 75 + ws.onclose = () => { 76 + log("disconnected"); 77 + onStateChange(false); 78 + if (!closed) { 79 + reconnectTimer = setTimeout(() => { 80 + reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); 81 + connect(); 82 + }, reconnectDelay); 83 + } 84 + }; 85 + 86 + ws.onerror = () => { 87 + // onclose fires after onerror, so we let that handle reconnect 88 + }; 89 + } 90 + 91 + function sendObject(trackId, groupId, objectId, payload) { 92 + if (ws && ws.readyState === WebSocket.OPEN) { 93 + ws.send(buildObjectFrame(trackId, groupId, objectId, payload)); 94 + } 95 + } 96 + 97 + function close() { 98 + closed = true; 99 + if (reconnectTimer) clearTimeout(reconnectTimer); 100 + if (ws) { 101 + ws.onclose = null; // prevent reconnect 102 + ws.close(); 103 + ws = null; 104 + } 105 + } 106 + 107 + connect(); 108 + 109 + return { close, sendObject }; 110 + }
-763
templates/partials/room_scripts.html
··· 1 - function encVarint(n) { 2 - if (n < 64) return new Uint8Array([n]); 3 - if (n < 16384) return new Uint8Array([(n >> 8) | 0x40, n & 0xff]); 4 - if (n < 1073741824) 5 - return new Uint8Array([(n >> 24) | 0x80, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]); 6 - const b = new Uint8Array(8); 7 - b[0] = (n >> 56) | 0xc0; 8 - b[1] = (n >> 48) & 0xff; 9 - b[2] = (n >> 40) & 0xff; 10 - b[3] = (n >> 32) & 0xff; 11 - b[4] = (n >> 24) & 0xff; 12 - b[5] = (n >> 16) & 0xff; 13 - b[6] = (n >> 8) & 0xff; 14 - b[7] = n & 0xff; 15 - return b; 16 - } 17 - function encString(s) { 18 - const bytes = new TextEncoder().encode(s); 19 - const len = encVarint(bytes.length); 20 - const out = new Uint8Array(len.length + bytes.length); 21 - out.set(len); 22 - out.set(bytes, len.length); 23 - return out; 24 - } 25 - function concat(arrays) { 26 - const total = arrays.reduce((sum, arr) => sum + arr.length, 0); 27 - const out = new Uint8Array(total); 28 - let off = 0; 29 - for (const a of arrays) { 30 - out.set(a, off); 31 - off += a.length; 32 - } 33 - return out; 34 - } 35 - 36 - function decVarint(buf, off) { 37 - const first = buf[off]; 38 - const tag = first >> 6; 39 - if (tag === 0) return [first & 0x3f, off + 1]; 40 - if (tag === 1) return [((first & 0x3f) << 8) | buf[off + 1], off + 2]; 41 - if (tag === 2) 42 - return [ 43 - ((first & 0x3f) << 24) | (buf[off + 1] << 16) | (buf[off + 2] << 8) | buf[off + 3], 44 - off + 4, 45 - ]; 46 - let val = BigInt(first & 0x3f) << 56n; 47 - for (let i = 1; i < 8; i++) val |= BigInt(buf[off + i]) << BigInt(8 * (7 - i)); 48 - return [Number(val), off + 8]; 49 - } 50 - function decString(buf, off) { 51 - const [len, o] = decVarint(buf, off); 52 - const str = new TextDecoder().decode(buf.slice(o, o + len)); 53 - return [str, o + len]; 54 - } 55 - 56 - const DEBUG = 57 - new URLSearchParams(location.search).has("debug") || localStorage.getItem("moqbox_debug"); 58 - function debug(...args) { 59 - if (DEBUG) console.log(...args); 60 - } 61 - 62 - const roomId = "{{ room_id }}"; 63 - let userName = sessionStorage.getItem("moqbox_user"); 64 - if (!userName) { 65 - userName = prompt("Enter your name:", "anonymous") || "anonymous"; 66 - sessionStorage.setItem("moqbox_user", userName); 67 - } 68 - 69 - const proto = location.protocol === "https:" ? "wss:" : "ws:"; 70 - let ws = null; 71 - let reconnectDelay = 1000; 72 - 73 - let mediaSource = null; 74 - let sourceBuffer = null; 75 - const pendingBoxes = []; // buffered while awaitingInit 76 - let currentTrackId = null; // tracks the currently playing track (from state) 77 - let currentGroupId = -1; // group_id from the latest state snapshot 78 - let processedGroupId = -1; // group_id of the last processed init segment 79 - let awaitingInit = true; // true until an init segment is received 80 - let currentStartedAt = 0; // latest started_at from state, for timer closure 81 - let seekInitDone = false; // true after first seek on join or track change 82 - 83 - function initVideo() { 84 - // Guard: don't create a second player if already initialized. 85 - if (document.getElementById("video-player")) return; 86 - debug("initVideo called"); 87 - const container = 88 - document.getElementById("video-container") || document.querySelector(".main-col"); 89 - const video = document.createElement("video"); 90 - video.id = "video-player"; 91 - video.controls = false; 92 - video.autoplay = true; 93 - video.muted = true; 94 - container.insertBefore(video, container.firstChild); 95 - 96 - // Wire up volume controls (element is in HTML, not created in JS) 97 - const volSlider = document.getElementById("vol-slider"); 98 - const volIcon = document.getElementById("vol-icon"); 99 - // Starts muted; user must gesture (click icon or drag slider) to enable audio. 100 - volSlider.addEventListener("input", () => { 101 - const v = parseFloat(volSlider.value); 102 - video.volume = v; 103 - video.muted = v === 0; 104 - volIcon.textContent = v === 0 ? "\u{1F507}" : v < 0.5 ? "\u{1F509}" : "\u{1F50A}"; 105 - }); 106 - volIcon.addEventListener("click", () => { 107 - if (video.muted) { 108 - video.muted = false; 109 - video.volume = 1; 110 - volSlider.value = "1"; 111 - volIcon.textContent = "\u{1F50A}"; 112 - } else { 113 - video.muted = true; 114 - volSlider.value = "0"; 115 - volIcon.textContent = "\u{1F507}"; 116 - } 117 - }); 118 - 119 - mediaSource = new MediaSource(); 120 - mediaSource.addEventListener("sourceopen", () => { 121 - debug("MediaSource sourceopen"); 122 - try { 123 - sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.4d0028,mp4a.40.2"'); 124 - sourceBuffer.mode = "segments"; 125 - sourceBuffer.addEventListener("updateend", () => { 126 - if (pendingBoxes.length > 0) { 127 - const next = pendingBoxes.shift(); 128 - try { 129 - sourceBuffer.appendBuffer(next); 130 - } catch (e) { 131 - console.warn("SourceBuffer append failed:", e); 132 - } 133 - } 134 - if (!seekInitDone) { 135 - const v = document.getElementById("video-player"); 136 - if (v && v.buffered.length > 0) { 137 - seekInitDone = true; 138 - v.currentTime = v.buffered.start(0); 139 - } 140 - } 141 - }); 142 - // Flush boxes that arrived before sourceopen. 143 - while (pendingBoxes.length > 0) { 144 - const box = pendingBoxes.shift(); 145 - if (!sourceBuffer.updating) { 146 - try { 147 - sourceBuffer.appendBuffer(box); 148 - } catch (e) { 149 - console.warn("SourceBuffer flush failed:", e); 150 - } 151 - } else { 152 - pendingBoxes.unshift(box); 153 - break; 154 - } 155 - } 156 - } catch (e) { 157 - console.warn("MediaSource not supported:", e); 158 - } 159 - }); 160 - video.src = URL.createObjectURL(mediaSource); 161 - } 162 - initVideo(); 163 - 164 - function appendVideo(data) { 165 - if (sourceBuffer && !sourceBuffer.updating) { 166 - try { 167 - sourceBuffer.appendBuffer(data); 168 - } catch (e) { 169 - console.warn("SourceBuffer append failed:", e); 170 - } 171 - } else { 172 - pendingBoxes.push(data); 173 - } 174 - } 175 - 176 - function handleVideoObject(groupId, data) { 177 - const isInit = 178 - data.length >= 8 && String.fromCharCode(data[4], data[5], data[6], data[7]) === "ftyp"; 179 - 180 - // Discard data from groups before our last processed init — stale old-track. 181 - if (groupId < processedGroupId) { 182 - debug("stale video data, discarding (group", groupId, "<", processedGroupId, ")"); 183 - return; 184 - } 185 - 186 - if (isInit) { 187 - debug("init segment received (group", groupId, ")"); 188 - processedGroupId = groupId; 189 - awaitingInit = false; 190 - pendingBoxes.length = 0; 191 - seekInitDone = false; 192 - 193 - // Reset sourceBuffer for the new track. 194 - if (sourceBuffer && !sourceBuffer.updating) { 195 - try { 196 - sourceBuffer.abort(); 197 - if (sourceBuffer.buffered.length > 0) { 198 - const end = sourceBuffer.buffered.end(sourceBuffer.buffered.length - 1); 199 - sourceBuffer.remove(0, end); 200 - } 201 - } catch (_) {} 202 - } 203 - appendVideo(data); 204 - return; 205 - } 206 - 207 - // Non-init data: buffer until we have an init for this track. 208 - if (awaitingInit) { 209 - pendingBoxes.push(data); 210 - return; 211 - } 212 - 213 - appendVideo(data); 214 - } 215 - 216 - function subscribeTracks() { 217 - // Subscribe to video track (MoQ objects carry group_id for sync). 218 - ws.send( 219 - concat([encVarint(0x01), encString("moqbox/room/" + roomId + "/video"), encString("video")]), 220 - ); 221 - // Subscribe to chat track. 222 - ws.send( 223 - concat([encVarint(0x01), encString("moqbox/room/" + roomId + "/chat"), encString("chat")]), 224 - ); 225 - // Subscribe to state track for now-playing updates. 226 - ws.send( 227 - concat([ 228 - encVarint(0x01), 229 - encString("moqbox/room/" + roomId + "/state"), 230 - encString("state"), 231 - ]), 232 - ); 233 - } 234 - 235 - function updateConnStatus(connected) { 236 - const el = document.getElementById("conn-status"); 237 - if (!el) return; 238 - el.className = "conn-status " + (connected ? "connected" : "disconnected"); 239 - el.textContent = connected ? "connected" : "disconnected"; 240 - } 241 - 242 - function connect() { 243 - ws = new WebSocket( 244 - proto + "//" + location.host + "/ws/" + roomId + "?name=" + encodeURIComponent(userName), 245 - ); 246 - ws.binaryType = "arraybuffer"; 247 - 248 - ws.onopen = () => { 249 - debug("WS connected"); 250 - updateConnStatus(true); 251 - reconnectDelay = 1000; 252 - if (elapsedInterval) { clearInterval(elapsedInterval); elapsedInterval = null; } 253 - // Reset sync state — control WS is the single source of truth. 254 - currentTrackId = null; 255 - currentGroupId = -1; 256 - processedGroupId = -1; 257 - awaitingInit = true; 258 - pendingBoxes.length = 0; 259 - seekInitDone = false; 260 - subscribeTracks(); 261 - }; 262 - 263 - ws.onmessage = (event) => { 264 - const buf = new Uint8Array(event.data); 265 - let off = 0; 266 - while (off < buf.length) { 267 - const [msgType, o1] = decVarint(buf, off); 268 - if (msgType === 0x00) { 269 - const [trackId, o2] = decVarint(buf, o1); 270 - const [groupId, o3] = decVarint(buf, o2); 271 - const [objectId, o4] = decVarint(buf, o3); 272 - if (trackId === 2) { 273 - const payload = new TextDecoder().decode(buf.slice(o4)); 274 - try { 275 - const chat = JSON.parse(payload); 276 - appendChat(chat); 277 - } catch (_) {} 278 - off = buf.length; 279 - } else if (trackId === 3) { 280 - const payload = new TextDecoder().decode(buf.slice(o4)); 281 - try { 282 - const state = JSON.parse(payload); 283 - updateNowPlaying(state); 284 - } catch (_) {} 285 - off = buf.length; 286 - } else if (trackId === 1) { 287 - // Video object — use group_id to synchronise with state. 288 - const payload = new Uint8Array(buf.slice(o4)); 289 - // group_id is available from the MoQ framing. 290 - handleVideoObject(groupId, payload); 291 - off = buf.length; 292 - } else { 293 - off = buf.length; 294 - } 295 - } else if (msgType === 0x02) { 296 - const [ns, o2] = decString(buf, o1); 297 - const [tr, o3] = decString(buf, o2); 298 - off = o3; 299 - } else if (msgType === 0x06) { 300 - const [ns, o2] = decString(buf, o1); 301 - const [tr, o3] = decString(buf, o2); 302 - const [reason, o4] = decString(buf, o3); 303 - off = o4; 304 - } else { 305 - break; 306 - } 307 - } 308 - }; 309 - 310 - ws.onclose = () => { 311 - updateConnStatus(false); 312 - setTimeout(() => { 313 - reconnectDelay = Math.min(reconnectDelay * 2, 30000); 314 - connect(); 315 - }, reconnectDelay); 316 - }; 317 - } 318 - 319 - connect(); 320 - 321 - 322 - 323 - function copyRoomLink() { 324 - const url = window.location.href; 325 - navigator.clipboard 326 - .writeText(url) 327 - .then(() => { 328 - const btn = document.getElementById("copy-link"); 329 - if (!btn) return; 330 - const original = btn.innerHTML; 331 - btn.innerHTML = '<span class="icon">&#x2713;</span>'; 332 - setTimeout(() => { 333 - btn.innerHTML = original; 334 - }, 1500); 335 - }) 336 - .catch(() => { 337 - const input = document.createElement("input"); 338 - input.value = url; 339 - document.body.appendChild(input); 340 - input.select(); 341 - document.execCommand("copy"); 342 - document.body.removeChild(input); 343 - }); 344 - } 345 - 346 - function appendChat(chat) { 347 - const container = document.getElementById("chat-messages"); 348 - const div = document.createElement("div"); 349 - div.className = "chat-msg" + (chat.type === "system_event" ? " system" : ""); 350 - 351 - let ts = ""; 352 - if (chat.ts) { 353 - const d = new Date(chat.ts); 354 - ts = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 355 - } 356 - 357 - div.innerHTML = 358 - '<span class="user">' + 359 - esc(chat.user || "anonymous") + 360 - "</span>" + 361 - '<span class="time">' + 362 - ts + 363 - "</span><br>" + 364 - '<span class="text">' + 365 - esc(chat.content || "") + 366 - "</span>"; 367 - container.appendChild(div); 368 - 369 - // Auto-scroll only if user is near the bottom (within 30px threshold). 370 - const threshold = 30; 371 - if (container.scrollHeight - container.scrollTop - container.clientHeight < threshold) { 372 - container.scrollTop = container.scrollHeight; 373 - } 374 - } 375 - 376 - let elapsedInterval = null; 377 - 378 - function parseDuration(s) { 379 - // Converts "M:SS" or "H:MM:SS" to seconds 380 - const parts = s.split(":").map(Number); 381 - if (parts.length === 2) return parts[0] * 60 + parts[1]; 382 - if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; 383 - return 0; 384 - } 385 - 386 - function updateNowPlaying(state) { 387 - const container = document.getElementById("video-container"); 388 - if (!container) return; 389 - 390 - // Update room name in top bar from state 391 - if (state.room_name) { 392 - const nameEl = document.getElementById("room-name"); 393 - if (nameEl) nameEl.textContent = state.room_name; 394 - } 395 - 396 - // Update viewer count pill 397 - const pill = document.getElementById("viewer-pill"); 398 - if (pill) { 399 - const c = state.clients || 0; 400 - pill.textContent = c === 1 ? "1 viewer" : c + " viewers"; 401 - } 402 - 403 - if (state.current_track) { 404 - const trackId = state.current_track.id; 405 - const newGroupId = state.current_track.group_id ?? -1; 406 - const trackChanged = currentTrackId !== trackId; 407 - 408 - // Detect track change — reset video state if we don't already have 409 - // the init for this track's pipeline (group_id comparison). 410 - if (trackChanged) { 411 - debug("track change", currentTrackId, "->", trackId); 412 - if (processedGroupId < newGroupId) { 413 - awaitingInit = true; // need to wait for new init 414 - pendingBoxes.length = 0; 415 - } 416 - seekInitDone = false; 417 - if (elapsedInterval) { 418 - clearInterval(elapsedInterval); 419 - elapsedInterval = null; 420 - } 421 - } 422 - currentTrackId = trackId; 423 - currentGroupId = newGroupId; 424 - 425 - // Always keep the module-level currentStartedAt up to date so the 426 - // elapsed timer (which reads it in its closure) gets the correct value 427 - // even when the second state update arrives (PipelineReady) and 428 - // replaces the placeholder started_at=0 with the real timestamp. 429 - currentStartedAt = state.current_track.started_at || 0; 430 - const sourceUrl = state.current_track.url || ""; 431 - 432 - // Show track overlay, hide idle message 433 - container.classList.remove("idle"); 434 - 435 - const toThumb = document.getElementById("to-thumb"); 436 - const toTitle = document.getElementById("to-title"); 437 - const toElapsed = document.getElementById("to-elapsed"); 438 - const toDuration = document.getElementById("to-duration"); 439 - const toViewers = document.getElementById("to-viewers"); 440 - const toSource = document.getElementById("to-source"); 441 - const toFill = document.getElementById("to-progress-fill"); 442 - const toOverlay = document.getElementById("track-overlay"); 443 - 444 - // Thumbnail 445 - if (state.current_track.thumbnail) { 446 - toThumb.src = state.current_track.thumbnail; 447 - toThumb.style.display = "block"; 448 - } else { 449 - toThumb.style.display = "none"; 450 - } 451 - 452 - // Title 453 - toTitle.textContent = state.current_track.title; 454 - 455 - // Duration 456 - toDuration.textContent = " / " + state.current_track.duration; 457 - 458 - // Source link 459 - if (sourceUrl) { 460 - toSource.href = sourceUrl; 461 - toSource.textContent = "source"; 462 - toSource.style.display = "inline"; 463 - } else { 464 - toSource.style.display = "none"; 465 - } 466 - 467 - // Rotation add button 468 - const toRot = document.getElementById("to-rot"); 469 - if (toRot) { 470 - toRot.style.display = sourceUrl ? "inline-flex" : "none"; 471 - toRot.onclick = () => addToRotation(sourceUrl); 472 - } 473 - 474 - // Viewers 475 - toViewers.textContent = state.clients 476 - ? " · " + state.clients + " viewer" + (state.clients !== 1 ? "s" : "") 477 - : ""; 478 - 479 - // Update elapsed display from module-level currentStartedAt (updated 480 - // on every state change — survives the closure lifetime). 481 - toElapsed.textContent = currentStartedAt ? formatTime((Date.now() - currentStartedAt) / 1000) : "0:00"; 482 - 483 - // Show overlay briefly on track change 484 - if (trackChanged) { 485 - toOverlay.classList.add("show"); 486 - setTimeout(() => toOverlay.classList.remove("show"), 3000); 487 - } 488 - 489 - // Start the elapsed timer. On track change the timer was cleared 490 - // above. When the second state update (PipelineReady) arrives with a 491 - // real started_at, this condition fires and creates the interval with 492 - // the correct currentStartedAt. 493 - if (!elapsedInterval && currentStartedAt) { 494 - elapsedInterval = setInterval(() => { 495 - if (!currentStartedAt) return; 496 - const elapsed = (Date.now() - currentStartedAt) / 1000; 497 - const elSpan = document.getElementById("to-elapsed"); 498 - if (elSpan) elSpan.textContent = formatTime(elapsed); 499 - const fill = document.getElementById("to-progress-fill"); 500 - if (fill) { 501 - const total = parseDuration(state.current_track.duration); 502 - if (total > 0) { 503 - fill.style.width = Math.min(100, (elapsed / total) * 100) + "%"; 504 - } 505 - } 506 - }, 1000); 507 - } 508 - 509 - // Forward-only sync: if video is more than 3 seconds behind 510 - // the live edge, seek forward to catch up. Never seek backward. 511 - const video = document.getElementById("video-player"); 512 - if (video && video.buffered.length > 0 && currentStartedAt) { 513 - const target = (Date.now() - currentStartedAt) / 1000; 514 - if (target > 0) { 515 - const behind = target - video.currentTime; 516 - if (behind > 3) { 517 - video.currentTime = target; 518 - } 519 - } 520 - } 521 - } else { 522 - if (elapsedInterval) { 523 - clearInterval(elapsedInterval); 524 - elapsedInterval = null; 525 - } 526 - currentTrackId = null; 527 - container.classList.add("idle"); 528 - } 529 - 530 - // Update queue and playlist from state. 531 - updateQueueDisplay(state); 532 - updatePlaylistDisplay(state); 533 - } 534 - 535 - function formatTime(secs) { 536 - const m = Math.floor(secs / 60); 537 - const s = Math.floor(secs % 60); 538 - return m + ":" + String(s).padStart(2, "0"); 539 - } 540 - 541 - function esc(s) { 542 - const d = document.createElement("div"); 543 - d.textContent = s; 544 - return d.innerHTML; 545 - } 546 - 547 - function safeUrl(url) { 548 - if (!url) return ""; 549 - if (url.startsWith("http://") || url.startsWith("https://")) { 550 - return esc(url); 551 - } 552 - return ""; 553 - } 554 - 555 - function renderTrackItem(track, actions, extraCls) { 556 - const cls = extraCls || ""; 557 - const thumb = track.thumbnail 558 - ? '<img src="' + esc(track.thumbnail) + '" class="thumb-img" alt="">' 559 - : '<div class="thumb"></div>'; 560 - let btns = ""; 561 - for (const a of actions) { 562 - if (a.cls === "source-link") { 563 - btns += 564 - '<a href="' + 565 - safeUrl(track.url) + 566 - '" target="_blank" class="source-link" rel="noopener noreferrer">source</a>'; 567 - } else { 568 - btns += 569 - '<button class="' + 570 - a.cls + 571 - '" data-track-url="' + 572 - esc(String(track.url || "")) + 573 - '" data-track-id="' + 574 - (track.id ?? "") + 575 - '" title="' + 576 - a.title + 577 - '">' + 578 - a.label + 579 - "</button>"; 580 - } 581 - } 582 - return ( 583 - '<li class="' + 584 - cls + 585 - '">' + 586 - thumb + 587 - '<div class="title">' + 588 - esc(track.title || "Loading...") + 589 - "</div>" + 590 - '<div class="duration">' + 591 - esc(track.duration || "--:--") + 592 - "</div>" + 593 - btns + 594 - "</li>" 595 - ); 596 - } 597 - 598 - function updateQueueDisplay(state) { 599 - const qSection = document.getElementById("queue-section"); 600 - const hSection = document.getElementById("history-section"); 601 - if (!qSection) return; 602 - 603 - const rotAction = { cls: "rot-add", label: "+", title: "Add to Rotation" }; 604 - const linkAction = { cls: "source-link", label: "source", title: "Open source" }; 605 - 606 - if (state.queue && state.queue.length > 0) { 607 - let html = '<ul class="queue">'; 608 - for (const item of state.queue) { 609 - html += renderTrackItem(item, [linkAction, rotAction], item.pending ? "pending" : ""); 610 - } 611 - html += "</ul>"; 612 - qSection.innerHTML = html; 613 - } else if (state.current_track) { 614 - qSection.innerHTML = 615 - '<div class="empty-state"><div class="icon">&#x25B6;</div><div class="label">No upcoming tracks</div><div class="hint">Add another link to keep the queue going.</div></div>'; 616 - } else { 617 - qSection.innerHTML = 618 - '<div class="empty-state"><div class="icon">&#x1F3B5;</div><div class="label">Queue is empty</div><div class="hint">Paste a link from YouTube, SoundCloud, or Bandcamp above to get started.</div></div>'; 619 - } 620 - 621 - if (hSection && state.history && state.history.length > 0) { 622 - let html = '<div class="history-header">Played</div><ul class="queue">'; 623 - for (const item of state.history) { 624 - html += renderTrackItem(item, [linkAction, rotAction], "history-item"); 625 - } 626 - html += "</ul>"; 627 - hSection.innerHTML = html; 628 - } else if (hSection) { 629 - hSection.innerHTML = ""; 630 - } 631 - } 632 - 633 - function updatePlaylistDisplay(state) { 634 - const container = document.getElementById("playlist-items"); 635 - if (!container) return; 636 - if (!state.playlist || state.playlist.length === 0) { 637 - container.innerHTML = '<div class="playlist-empty">Rotation is empty</div>'; 638 - return; 639 - } 640 - const removeAction = { cls: "playlist-remove", label: "\u2715", title: "Remove from Rotation" }; 641 - const linkAction = { cls: "source-link", label: "source", title: "Open source" }; 642 - let html = '<ul class="queue">'; 643 - for (const entry of state.playlist) { 644 - html += renderTrackItem(entry, [linkAction, removeAction]); 645 - } 646 - html += "</ul>"; 647 - container.innerHTML = html; 648 - } 649 - 650 - document.addEventListener("click", (e) => { 651 - const btn = e.target.closest(".rot-add, .playlist-remove"); 652 - if (!btn) return; 653 - const url = btn.dataset.trackUrl; 654 - const id = btn.dataset.trackId; 655 - if (btn.classList.contains("rot-add") && url) { 656 - e.preventDefault(); 657 - addToRotation(url); 658 - } else if (btn.classList.contains("playlist-remove") && id) { 659 - e.preventDefault(); 660 - removeFromPlaylist(id); 661 - } 662 - }); 663 - 664 - // Send chat 665 - document.getElementById("chat-send").addEventListener("click", sendChat); 666 - document.getElementById("chat-input").addEventListener("keydown", (e) => { 667 - if (e.key === "Enter") sendChat(); 668 - }); 669 - 670 - function sendChat() { 671 - const input = document.getElementById("chat-input"); 672 - const content = input.value.trim(); 673 - if (!content) return; 674 - const payload = new TextEncoder().encode(JSON.stringify({ content })); 675 - const obj = concat([ 676 - encVarint(0x00), 677 - encVarint(2), // track_id = Chat 678 - encVarint(0), // group_id 679 - encVarint(0), // object_id 680 - payload, 681 - ]); 682 - ws.send(obj); 683 - input.value = ""; 684 - } 685 - 686 - async function ingest() { 687 - const input = document.getElementById("url-input"); 688 - const btn = document.getElementById("queue-add-btn"); 689 - if (!input.value.trim()) return; 690 - btn.disabled = true; 691 - btn.textContent = "Adding…"; 692 - try { 693 - const resp = await fetch("/api/ingest", { 694 - method: "POST", 695 - headers: { "Content-Type": "application/json" }, 696 - body: JSON.stringify({ url: input.value, room_id: roomId }), 697 - }); 698 - if (resp.ok) { 699 - input.value = ""; 700 - } else { 701 - const err = await resp.json(); 702 - alert(err.error || "ingest failed"); 703 - } 704 - } finally { 705 - btn.disabled = false; 706 - btn.textContent = "Add"; 707 - } 708 - } 709 - 710 - async function skip() { 711 - await fetch("/api/skip", { 712 - method: "POST", 713 - headers: { "Content-Type": "application/json" }, 714 - body: JSON.stringify({ room_id: roomId }), 715 - }); 716 - } 717 - 718 - async function addToPlaylist() { 719 - const input = document.getElementById("playlist-input"); 720 - const btn = document.querySelector("#playlist-section .btn-primary"); 721 - if (!input.value.trim()) return; 722 - btn.disabled = true; 723 - btn.textContent = "Adding…"; 724 - try { 725 - const resp = await fetch("/api/playlist/add", { 726 - method: "POST", 727 - headers: { "Content-Type": "application/json" }, 728 - body: JSON.stringify({ url: input.value, room_id: roomId }), 729 - }); 730 - if (resp.ok) { 731 - input.value = ""; 732 - } else { 733 - const err = await resp.json(); 734 - alert(err.error || "add to playlist failed"); 735 - } 736 - } finally { 737 - btn.disabled = false; 738 - btn.textContent = "Add"; 739 - } 740 - } 741 - 742 - async function removeFromPlaylist(id) { 743 - await fetch("/api/playlist/remove", { 744 - method: "POST", 745 - headers: { "Content-Type": "application/json" }, 746 - body: JSON.stringify({ room_id: roomId, id }), 747 - }); 748 - } 749 - 750 - async function addToRotation(url) { 751 - await fetch("/api/playlist/add", { 752 - method: "POST", 753 - headers: { "Content-Type": "application/json" }, 754 - body: JSON.stringify({ url, room_id: roomId }), 755 - }); 756 - } 757 - 758 - document.getElementById("playlist-input").addEventListener("keydown", (e) => { 759 - if (e.key === "Enter") addToPlaylist(); 760 - }); 761 - document.getElementById("url-input").addEventListener("keydown", (e) => { 762 - if (e.key === "Enter") ingest(); 763 - });
+5 -7
templates/room.html
··· 22 22 <span class="room-id">{{ room_id }}</span> 23 23 </div> 24 24 <div class="top-bar-actions"> 25 - <button class="btn-icon" id="copy-link" title="Copy room link" onclick="copyRoomLink()"> 25 + <button class="btn-icon" id="copy-link" title="Copy room link"> 26 26 <span class="icon">&#x1F517;</span> 27 27 </button> 28 28 <span class="viewer-pill" id="viewer-pill">0 viewers</span> ··· 98 98 id="url-input" 99 99 placeholder="Paste YouTube / SoundCloud / Bandcamp URL..." 100 100 > 101 - <button id="queue-add-btn" class="btn-primary" onclick="ingest()">Add</button> 102 - <button class="btn-secondary" onclick="skip()" title="Skip current track">Skip</button> 101 + <button id="queue-add-btn" class="btn-primary">Add</button> 102 + <button id="skip-btn" class="btn-secondary" title="Skip current track">Skip</button> 103 103 </div> 104 104 </div> 105 105 ··· 134 134 id="playlist-input" 135 135 placeholder="Add URL to Rotation..." 136 136 > 137 - <button class="btn-primary" onclick="addToPlaylist()">Add</button> 137 + <button id="playlist-add-btn" class="btn-primary">Add</button> 138 138 </div> 139 139 </div> 140 140 </div> ··· 149 149 </div> 150 150 </div> 151 151 152 - <script> 153 - {%- include "partials/room_scripts.html" -%} 154 - </script> 152 + <script type="module" src="/static/moqbox/main.js"></script> 155 153 </body> 156 154 </html>
+4
treefmt.toml
··· 8 8 command = "rustfmt" 9 9 includes = ["*.rs"] 10 10 11 + [formatter.javascript] 12 + command = "biome" 13 + options = ["format", "--write"] 14 + includes = ["*.js"] 11 15