import "./style.css"; type HlsClass = (typeof import("hls.js"))["default"]; declare global { interface Window { switchRoom: (room: string) => void; sendMessage: () => void; togglePrivateDM: () => void; toggleChatPanel: () => void; } } let hls: InstanceType | null = null; let ws: WebSocket | null = null; let currentRoom = "main"; let username = "viewer" + Math.floor(Math.random() * 1000); let authenticated = false; let chatVisible = true; let typingUser = ""; let lastTypingSent = 0; let typingTimeout: number | undefined; let flashTimeout: number | undefined; let flashFadeTimeout: number | undefined; const onlineUsers = new Set(); // Player error recovery state (STREAMING.md contract) let playerErrorCount = 0; const PLAYER_MAX_RETRIES = 5; const PLAYER_BASE_DELAY_MS = 2000; export function resetViewerStateForTests() { hls = null; ws = null; currentRoom = "main"; username = "viewer"; authenticated = false; chatVisible = true; typingUser = ""; lastTypingSent = 0; onlineUsers.clear(); playerErrorCount = 0; } export function resolveWebSocketBaseUrl() { const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim(); if (configuredUrl) { return configuredUrl; } const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${window.location.host}/ws`; } export function resolveManifestUrl() { const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim(); const cacheBust = `stream=${Date.now()}`; const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin); sameOriginManifestUrl.searchParams.set("stream", cacheBust); const allowCrossOriginManifest = import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === "true"; if (configuredUrl) { const manifestUrl = new URL(configuredUrl); manifestUrl.searchParams.set("stream", cacheBust); if (!allowCrossOriginManifest && manifestUrl.origin !== window.location.origin) { return sameOriginManifestUrl.toString(); } return manifestUrl.toString(); } return sameOriginManifestUrl.toString(); } function connectWebSocket(room: string) { // Clear previous messages so reconnects render a fresh history instead of // appending duplicates on top of the old log. clearMessages(); // Detach the old socket's close handler before closing it. Otherwise an // intentional reconnect (login/logout/room switch/session refresh) would // have the replaced socket schedule yet another reconnect, and each new // connection re-pulls the room's history — filling the panel with old // messages on a loop. if (ws) { ws.onclose = null; ws.close(); } const socketUrl = new URL(resolveWebSocketBaseUrl()); socketUrl.searchParams.set("username", username); socketUrl.searchParams.set("room", room); const socket = new WebSocket(socketUrl); ws = socket; socket.onopen = () => { console.log(`Connected to chat room: ${room}`); addMessage("system", "Connected to " + room); }; socket.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === "typing") { showTyping(msg.username); return; } hideTyping(msg.username); // Presence join/leave announcements become transient flashes, not chat rows. if (msg.type === "system" && msg.username === "system") { const joined = msg.text?.match(/^(\S+) joined /); const left = msg.text?.match(/^(\S+) left /); if (joined) { onlineUsers.add(joined[1]); renderPresence(); showFlash(`${joined[1]} joined`); return; } else if (left) { onlineUsers.delete(left[1]); renderPresence(); showFlash(`${left[1]} left`); return; } } addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp); }; socket.onclose = () => { // Ignore close events from sockets that were intentionally replaced. if (ws !== socket) return; ws = null; console.log("Chat disconnected. Reconnecting in 3s..."); setTimeout(() => connectWebSocket(room), 3000); }; } function clearMessages() { const messages = document.getElementById("messages"); if (messages) messages.innerHTML = ""; } function addMessage(type: string, text: string, sender = "", timestamp = new Date()) { const messages = document.getElementById("messages"); if (!messages) return; const div = document.createElement("div"); div.className = "message-row"; if (type === "system") { div.classList.add("message-system-row"); div.innerHTML = `
${text}
`; } else { div.classList.add(sender === username ? "message-self-row" : "message-other-row"); div.innerHTML = `
${sender && sender !== username ? `
${sender}
` : ""}
${text}
`; } messages.appendChild(div); messages.scrollTop = messages.scrollHeight; } function showFlash(text: string) { const el = document.getElementById("chat-flash"); if (!el) return; el.textContent = text; el.hidden = false; el.classList.remove("fade-out"); window.clearTimeout(flashTimeout); window.clearTimeout(flashFadeTimeout); flashTimeout = window.setTimeout(() => { el.classList.add("fade-out"); flashFadeTimeout = window.setTimeout(() => { el.hidden = true; el.classList.remove("fade-out"); }, 500); }, 3000); } function renderPresence() { const list = document.getElementById("presence-list"); if (!list) return; const sorted = [...onlineUsers].sort(); list.innerHTML = sorted .map((u) => `${u}`) .join(""); } async function refreshSchedule() { try { const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; const res = await fetch(`${apiUrl}/api/schedule`); if (!res.ok) return; const data = await res.json(); const entries = data.entries as Array<{ title: string; status: string }>; if (entries.length > 0) { const np = document.getElementById("np-title"); const nn = document.getElementById("np-next"); if (np) np.textContent = `Now: ${entries[0].title}`; if (nn && entries.length > 1) { nn.textContent = `Up next: ${entries[1].title}`; } } } catch { // Schedule unavailable — non-critical } } export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: HlsClass }> = () => import("hls.js")) { const video = document.getElementById("player") as HTMLVideoElement; const manifestUrl = resolveManifestUrl(); if (video.canPlayType("application/vnd.apple.mpegurl")) { video.src = manifestUrl; video.addEventListener("loadedmetadata", () => video.play()); } else { try { const { default: Hls } = await loadHlsModule(); if (Hls.isSupported()) { hls = new Hls({ liveDurationInfinity: true, liveSyncDurationCount: 3, }); hls.loadSource(manifestUrl); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => video.play()); // Player error recovery (STREAMING.md contract) hls.on(Hls.Events.ERROR, (_event, data) => { if (!data.fatal) return; playerErrorCount++; switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: if (playerErrorCount <= PLAYER_MAX_RETRIES) { const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1); console.warn( `HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`, ); showReconnecting("Reconnecting..."); setTimeout(() => { hls?.startLoad(); }, delay); } else { console.error("HLS network error: max retries exhausted"); showReconnecting("Stream temporarily unavailable"); } break; case Hls.ErrorTypes.MEDIA_ERROR: if (playerErrorCount <= 1) { console.warn("HLS media error, attempting recovery..."); showReconnecting("Recovering..."); hls!.recoverMediaError(); } else { console.error("HLS media error: recovery failed"); hls!.destroy(); showReconnecting("Stream temporarily unavailable"); } break; default: console.error("HLS fatal error:", data.type, data.details); hls!.destroy(); showReconnecting("Stream temporarily unavailable"); break; } }); // Reset error count on successful manifest parse hls.on(Hls.Events.MANIFEST_PARSED, () => { playerErrorCount = 0; hideReconnecting(); }); } } catch (error) { console.error("Failed to load HLS player", error); addMessage("system", "Unable to initialize video player."); } } // Resolve the user session first so we connect once with the correct username // (avoids pushing chat history twice on initial load). await sessionReady; connectWebSocket("main"); // Start schedule polling refreshSchedule(); setInterval(refreshSchedule, 30000); console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready"); } export function showReconnecting(message = "Reconnecting...") { const overlay = document.getElementById("reconnecting-overlay"); const text = document.getElementById("reconnecting-text"); if (overlay) { overlay.classList.remove("hidden"); } if (text) { text.textContent = message; } } export function hideReconnecting() { const overlay = document.getElementById("reconnecting-overlay"); if (overlay) { overlay.classList.add("hidden"); } } function toggleChatPanel() { const shell = document.querySelector(".viewer-shell"); const toggle = document.getElementById("chat-toggle"); if (!shell || !toggle) return; chatVisible = !chatVisible; shell.classList.toggle("chat-collapsed", !chatVisible); toggle.textContent = chatVisible ? "Hide Chat" : "Show Chat"; toggle.setAttribute("aria-expanded", String(chatVisible)); } function sendMessage() { const input = document.getElementById("message-input") as HTMLInputElement; const text = input.value.trim(); if (!text || !ws) return; const msg = { room: currentRoom, username: username, text: text, type: currentRoom.startsWith("dm:") ? "private" : "public", }; ws.send(JSON.stringify(msg)); input.value = ""; } function switchRoom(room: string) { currentRoom = room; document.getElementById("room-name")!.textContent = room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room"; connectWebSocket(room); } function togglePrivateDM() { const target = prompt("Enter username to start private chat with:"); if (target && target !== username) { const dmRoom = `dm:${[username, target].sort().join("-")}`; switchRoom(dmRoom); } } window.switchRoom = switchRoom; window.sendMessage = sendMessage; window.togglePrivateDM = togglePrivateDM; window.toggleChatPanel = toggleChatPanel; function openAuthModal() { const panel = document.getElementById("auth-panel"); if (panel) panel.hidden = false; } function closeAuthModal() { const panel = document.getElementById("auth-panel"); if (panel) panel.hidden = true; } function updateAuthUI() { const label = document.getElementById("chat-user-label"); const toggle = document.getElementById("auth-toggle-btn"); const logoutBtn = document.getElementById("auth-logout-btn"); const panel = document.getElementById("auth-panel"); if (label) { label.textContent = authenticated ? username : ""; label.hidden = !authenticated; } if (toggle) toggle.hidden = authenticated; if (logoutBtn) logoutBtn.hidden = !authenticated; if (panel) panel.hidden = true; } let sessionReady: Promise = Promise.resolve(); function refreshUserSession(): void { sessionReady = (async () => { try { const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" }); if (!res.ok) return; const data = await res.json(); if (data.authenticated && data.username) { authenticated = true; username = data.username; } else { authenticated = false; } updateAuthUI(); } catch { /* non-critical */ } })(); } async function submitAuth(path: string) { const u = (document.getElementById("auth-username") as HTMLInputElement).value.trim(); const p = (document.getElementById("auth-password") as HTMLInputElement).value; const status = document.getElementById("auth-status"); if (!u || !p) { if (status) { status.textContent = "Username and password required."; status.className = "status-line status-error"; } return; } try { const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; const res = await fetch(`${apiUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u, password: p }), }); if (!res.ok) { const text = (await res.text()).trim(); throw new Error(text || `HTTP ${res.status}`); } const data = await res.json(); username = data.username || u; authenticated = true; updateAuthUI(); if (status) { status.textContent = `Signed in as ${username}.`; status.className = "status-line"; } connectWebSocket(currentRoom); } catch (err) { if (status) { status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`; status.className = "status-line status-error"; } } } function login() { void submitAuth("/api/auth/login"); } function register() { void submitAuth("/api/auth/register"); } async function logout() { try { const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; await fetch(`${apiUrl}/api/auth/logout`, { method: "POST", credentials: "include" }); } catch { /* ignore */ } authenticated = false; username = "viewer" + Math.floor(Math.random() * 1000); updateAuthUI(); connectWebSocket(currentRoom); } function showTyping(name: string) { if (name === username) return; const el = document.getElementById("typing-indicator"); if (!el) return; typingUser = name; el.textContent = `${name} is typing...`; el.hidden = false; window.clearTimeout(typingTimeout); typingTimeout = window.setTimeout(() => { typingUser = ""; el.hidden = true; el.textContent = ""; }, 3000); } function hideTyping(name: string) { if (name !== typingUser) return; const el = document.getElementById("typing-indicator"); typingUser = ""; window.clearTimeout(typingTimeout); if (el) { el.hidden = true; el.textContent = ""; } } function sendTyping() { if (!ws || ws.readyState !== WebSocket.OPEN) return; const now = Date.now(); if (now - lastTypingSent < 2000) return; lastTypingSent = now; ws.send(JSON.stringify({ room: currentRoom, username, text: "", type: "typing" })); } document.addEventListener("DOMContentLoaded", () => { void initPlayerAndChat(); refreshUserSession(); document.getElementById("auth-toggle-btn")?.addEventListener("click", () => { if (!authenticated) openAuthModal(); }); document.getElementById("auth-close-btn")?.addEventListener("click", closeAuthModal); document.getElementById("auth-backdrop")?.addEventListener("click", closeAuthModal); document.getElementById("auth-login-btn")?.addEventListener("click", login); document.getElementById("auth-register-btn")?.addEventListener("click", register); document.getElementById("auth-logout-btn")?.addEventListener("click", logout); document.getElementById("message-input")?.addEventListener("input", sendTyping); });