233 lines
7.0 KiB
TypeScript
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();
|
|
});
|