Files
linearvoid/frontend/src/main.ts
T
zwitschi f9aba11b90
CD / deploy (push) Successful in 57s
CI / test (push) Failing after 1m29s
CI / docker (push) Skipped
feat: add metrics, observability, test fixtures, and scaling playbook artifacts
2026-08-10 00:23:50 +02:00

233 lines
7.0 KiB
TypeScript

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<HlsClass> | null = null;
let ws: WebSocket | null = null;
let currentRoom = "main";
let username = "viewer" + Math.floor(Math.random() * 1000);
let chatVisible = true;
const onlineUsers = new Set<string>();
export function resetViewerStateForTests() {
hls = null;
ws = null;
currentRoom = "main";
username = "viewer";
chatVisible = true;
onlineUsers.clear();
}
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) {
if (ws) ws.close();
const socketUrl = new URL(resolveWebSocketBaseUrl());
socketUrl.searchParams.set("username", username);
socketUrl.searchParams.set("room", room);
ws = new WebSocket(socketUrl);
ws.onopen = () => {
console.log(`Connected to chat room: ${room}`);
addMessage("system", "Connected to " + room);
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
// Track presence from system join/leave messages
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();
} else if (left) {
onlineUsers.delete(left[1]);
renderPresence();
}
}
};
ws.onclose = () => {
console.log("Chat disconnected. Reconnecting in 3s...");
setTimeout(() => connectWebSocket(room), 3000);
};
}
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
const messages = document.getElementById("messages")!;
const div = document.createElement("div");
div.className = "message-row";
if (type === "system") {
div.classList.add("message-system-row");
div.innerHTML = `<div class="message-system">${text}</div>`;
} else {
div.classList.add(sender === username ? "message-self-row" : "message-other-row");
div.innerHTML = `
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
<div class="message-text">${text}</div>
<time class="message-time">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time>
</article>
`;
}
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function renderPresence() {
const list = document.getElementById("presence-list");
if (!list) return;
const sorted = [...onlineUsers].sort();
list.innerHTML = sorted
.map((u) => `<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`)
.join("");
}
async function refreshSchedule() {
try {
const res = await fetch("/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();
hls.loadSource(manifestUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
}
} catch (error) {
console.error("Failed to load HLS player", error);
addMessage("system", "Unable to initialize video player.");
}
}
connectWebSocket("main");
// Start schedule polling
refreshSchedule();
setInterval(refreshSchedule, 30000);
console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
}
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";
addMessage("system", `Switched to ${room} 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;
document.addEventListener("DOMContentLoaded", () => {
void initPlayerAndChat();
});