window.addEventListener("DOMContentLoaded", () => { const websocket = new ReconnectingWebSocket("wss://lilac.thewisewolf.dev/tracker/timer/ws"); communicate(websocket); }); class Timer { constructor(renderer, timer_data) { this.renderer = renderer; this.destroying = false; this.update_from_data(timer_data); } update_from_data(timer_data) { this.end_at = new Date(timer_data.end_at); this.running = timer_data.is_running; this.name = timer_data.name; this.score_table = timer_data.score_table; this.total_contribution = timer_data.total_contribution; this.total_subpoints = timer_data.total_subpoints; this.total_bits = timer_data.total_bits; this.goals_met = timer_data.goals_met; this.goals_total = timer_data.goals_total; this.last_goal = timer_data.last_goal; this.next_goal = timer_data.next_goal; this.goals = timer_data.goals; this.last_contribution = timer_data.last_contribution; this.leaderboard = timer_data.leaderboard; } render_time() { this.renderer.render_time( Math.floor((this.end_at - new Date()) / 1000) ); } render() { // Render goal if (this.next_goal != null) { this.renderer.render_current_goal( this.next_goal.name, this.total_contribution, this.next_goal.required, ); } else if (this.last_goal != null) { this.renderer.render_current_goal( this.last_goal.name, this.total_contribution, this.last_goal.required, ); } else { this.renderer.clear_current_goal(); } // Render leaderboard this.renderer.render_users(this.leaderboard); // Render timer this.render_time(); } /** * Recursive call run per second to update the rendered timer */ tick() { if (!this.destroying) { this.render_time(); if (this.running) { setTimeout(this.tick.bind(this), 1000); } } } } class TimerRenderer { constructor() { // HH:MM or MM:SS this.timer = document.getElementById("Timer"); // Current Goal: this.goal_label = document.getElementById("GoalLabel") // Name of the goal to display this.goal_name = document.getElementById("GoalName") // 1524/3000 Points this.goal_progress = document.getElementById("GoalProgress") // Top 3 Gifters this.topusers_label = document.getElementById("TopUsers") // Leaderboard items // 1. (or points?) this.topusers_user1_num = document.getElementById("GiftUserNum1") // Name of user 1 this.topusers_user1_name = document.getElementById("GiftUserName1") this.topusers_user2_num = document.getElementById("GiftUserNum2") this.topusers_user2_name = document.getElementById("GiftUserName2") this.topusers_user3_num = document.getElementById("GiftUserNum3") this.topusers_user3_name = document.getElementById("GiftUserName3") } /** * Bring the display into a standard initial state. */ reset() { this.timer.textContent = "00:00"; this.goal_label.textContent = "Current Goal:"; this.goal_name.textContent = "Be awesome"; this.goal_progress.textContent = "00/00 Points"; this.topusers_label.textContent = "Top 3 Gifters"; this.topusers_user1_num.textContent = "1."; this.topusers_user1_name.textContent = "Anonymous"; this.topusers_user2_num.textContent = "2."; this.topusers_user2_name.textContent = "Anonymous"; this.topusers_user3_num.textContent = "3."; this.topusers_user3_name.textContent = "Anonymous"; } /** * Render the timer from a time remaining. * * @param {integer} seconds_remaining - The number of seconds remaining on the timer. */ render_time(seconds_remaining) { var timestr; // Render a time remaining given in seconds if (seconds_remaining <= 0) { timestr = "00:00"; } else { var seconds = seconds_remaining % 60; seconds_remaining = seconds_remaining - seconds; var minutes = Math.floor(seconds_remaining / 60); var hours = Math.floor(minutes / 60); minutes = minutes - 60 * hours; // Change rendering mode based on hours or minutes left if (hours > 0) { timestr = String(hours).padStart(2, '0') + ':' + String(minutes).padStart(2, '0'); } else { timestr = String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0'); } } this.timer.textContent = timestr; } /** * Render the current goal. * * @param {string} name - The name of the current goal. * @param {number} current - The number of points we currently have. * @param {number} required - The number of points to achieve the goal. */ render_current_goal(name, current, required) { // TODO: Some kind of end condition, ability to clear a goal etc // E.g. if subathon doesn't have a goal at all // Or after we accomplish all goals this.goal_name.textContent = name; // Bitwise OR done to convert floats to integers if needed this.goal_progress.textContent = String(current | 0) + '/' + String(required | 0) + ' Points' } /** * Clear the current goal field. */ clear_current_goal() { this.goal_name.textContent = ""; this.goal_progress.textContent = ""; this.goal_label.textContent = ""; } /** * Render the user leaderboard. * * @param {Object[]} users - The user leaderboard in descending (points, name) order. * @param {integer} users[].amount - The number of points this users has contrib. * @param {string} users[].user_name - The name of this user. */ render_users(users) { if (users.length >= 1) { this.topusers_user1_num.textContent = "1."; this.topusers_user1_name.textContent = users[0].user_name; } else { this.topusers_user1_num.textContent = ""; this.topusers_user1_name.textContent = ""; } if (users.length >= 2) { this.topusers_user2_num.textContent = "2."; this.topusers_user2_name.textContent = users[1].user_name; } else { this.topusers_user2_num.textContent = ""; this.topusers_user2_name.textContent = ""; } if (users.length >= 3) { this.topusers_user3_num.textContent = "3."; this.topusers_user3_name.textContent = users[2].user_name; } else { this.topusers_user3_num.textContent = ""; this.topusers_user3_name.textContent = ""; } } } class ReconnectingWebSocket { constructor(url) { this.url = url; this.ws = null; this.attempt = 0; this.connect(); this.closing = false; this.onopen = null; this.onmessage = null; this.onclose = null; this.onerror = null; } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { console.log('WebSocket connected'); this.attempt = 0; if (this.onopen != null) { this.onopen(); } }; this.ws.onmessage = (msg) => { console.log('Received:', msg.data); if (this.onmessage != null) { this.onmessage(msg); } }; this.ws.onclose = () => { if (this.closing) { console.log('Closing Websocket.') } { console.warn('WebSocket closed. Reconnecting...'); this.reconnect(); } }; this.ws.onerror = (err) => { console.error('WebSocket error:', err); if (this.onerror != null) { this.onerror(); } this.close(); }; } reconnect() { const delay = this.getBackoffDelay(this.attempt); console.log(`Reconnecting in ${delay}ms`); setTimeout(() => { this.attempt++; this.connect(); }, delay); } close() { this.closing = true; this.ws.close(); if (this.onclose != null) { this.onclose(); } } getBackoffDelay(attempt) { const base = 500; // 0.5 second const max = 30000; // 30 seconds const jitter = Math.random() * 1000; return Math.min(base * 2 ** attempt + jitter, max); } send(data) { if (this.ws.readyState === WebSocket.OPEN) { this.ws.send(data); } else { console.warn('WebSocket not connected'); } } } function communicate(websocket) { console.log("Communicating"); const params = new URLSearchParams(window.location.search); var renderer = new TimerRenderer(); let timer; websocket.onopen = () => { websocket.send( JSON.stringify( { type: "init", channel: "SubTimer", community: params.get('community') } ) ); renderer.reset(); }; websocket.onmessage = ({ data }) => { const event = JSON.parse(data); switch (event.type) { case "DO": let args = event.args; // Call the specified method switch (event.method) { case "setTimer": if (timer == null) { timer = new Timer(renderer, args); } else { timer.update_from_data(args); } timer.render(); timer.tick(); break; case "noTimer": if (timer != null) { timer.destroying = true; timer = null; } renderer.reset(); case "endTimer": if (timer != null) { timer.destroying = true; timer = null; } default: throw new Error(`Unsupported method requested: ${event.method}.`) } break; default: throw new Error(`Unsupported event type: ${event.type}.`); } }; }