feat: runtime config, Matomo analytics, chat accounts, DM history/search, and Prometheus retention
This commit is contained in:
1 parent
06ab5b4655
commit
121fdbacdc
27 files changed
+1841
-161
No files matched your search
+185
-1
@@ -15,7 +15,13 @@ let hls: InstanceType<HlsClass> | null = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let currentRoom = "main";
|
||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
let authenticated = false;
|
||||
let currentDMTarget = "";
|
||||
let chatVisible = true;
|
||||
let usersVisible = true;
|
||||
let typingUser = "";
|
||||
let lastTypingSent = 0;
|
||||
let typingTimeout: number | undefined;
|
||||
const onlineUsers = new Set<string>();
|
||||
|
||||
// Player error recovery state (STREAMING.md contract)
|
||||
@@ -28,7 +34,11 @@ export function resetViewerStateForTests() {
|
||||
ws = null;
|
||||
currentRoom = "main";
|
||||
username = "viewer";
|
||||
authenticated = false;
|
||||
chatVisible = true;
|
||||
usersVisible = true;
|
||||
typingUser = "";
|
||||
lastTypingSent = 0;
|
||||
onlineUsers.clear();
|
||||
playerErrorCount = 0;
|
||||
}
|
||||
@@ -81,6 +91,11 @@ function connectWebSocket(room: string) {
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "typing") {
|
||||
showTyping(msg.username);
|
||||
return;
|
||||
}
|
||||
hideTyping(msg.username);
|
||||
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
||||
|
||||
// Track presence from system join/leave messages
|
||||
@@ -183,7 +198,9 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
|
||||
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...`);
|
||||
console.warn(
|
||||
`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`,
|
||||
);
|
||||
showReconnecting("Reconnecting...");
|
||||
setTimeout(() => {
|
||||
hls?.startLoad();
|
||||
@@ -282,25 +299,192 @@ function sendMessage() {
|
||||
|
||||
function switchRoom(room: string) {
|
||||
currentRoom = room;
|
||||
const isDM = room.startsWith("dm:");
|
||||
document.getElementById("room-name")!.textContent =
|
||||
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
||||
addMessage("system", `Switched to ${room} room`);
|
||||
const searchRow = document.getElementById("dm-search-row");
|
||||
if (searchRow) searchRow.hidden = !isDM;
|
||||
connectWebSocket(room);
|
||||
}
|
||||
|
||||
function togglePrivateDM() {
|
||||
const target = prompt("Enter username to start private chat with:");
|
||||
if (target && target !== username) {
|
||||
currentDMTarget = target;
|
||||
const dmRoom = `dm:${[username, target].sort().join("-")}`;
|
||||
switchRoom(dmRoom);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchDM() {
|
||||
const input = document.getElementById("dm-search-input") as HTMLInputElement;
|
||||
const q = input.value.trim();
|
||||
if (!q || !currentDMTarget) return;
|
||||
try {
|
||||
const res = await fetch(`/api/dm/search?with=${encodeURIComponent(currentDMTarget)}&q=${encodeURIComponent(q)}`);
|
||||
if (!res.ok) return;
|
||||
const results = await res.json();
|
||||
addMessage("system", `Search results (${results.length}) for "${q}":`);
|
||||
for (const m of results) {
|
||||
addMessage(m.type || "private", m.text, m.username, m.timestamp);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
window.switchRoom = switchRoom;
|
||||
window.sendMessage = sendMessage;
|
||||
window.togglePrivateDM = togglePrivateDM;
|
||||
window.toggleChatPanel = toggleChatPanel;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function refreshUserSession() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const wasAnonymous = username.startsWith("viewer");
|
||||
if (data.authenticated && data.username) {
|
||||
authenticated = true;
|
||||
username = data.username;
|
||||
updateAuthUI();
|
||||
if (wasAnonymous) connectWebSocket(currentRoom);
|
||||
} 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 res = await fetch(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 {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
authenticated = false;
|
||||
username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
updateAuthUI();
|
||||
connectWebSocket(currentRoom);
|
||||
}
|
||||
|
||||
function toggleUserList() {
|
||||
const panel = document.getElementById("presence-panel");
|
||||
const btn = document.getElementById("users-toggle-btn");
|
||||
if (!panel) return;
|
||||
usersVisible = !usersVisible;
|
||||
panel.hidden = !usersVisible;
|
||||
if (btn) btn.setAttribute("aria-expanded", String(usersVisible));
|
||||
}
|
||||
|
||||
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();
|
||||
void refreshUserSession();
|
||||
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
|
||||
const panel = document.getElementById("auth-panel");
|
||||
if (panel) panel.hidden = !panel.hidden;
|
||||
});
|
||||
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("dm-search-btn")?.addEventListener("click", searchDM);
|
||||
document.getElementById("users-toggle-btn")?.addEventListener("click", toggleUserList);
|
||||
document.getElementById("message-input")?.addEventListener("input", sendTyping);
|
||||
});
|
||||
Reference in new issue
Block a user