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.ended = 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; } check_ended() { if (!this.ended && this.end_at < new Date()) { this.ended = true; this.renderer.finale(); } return this.ended; } set_ended() { if (!this.ended && this.end_at < new Date()) { this.ended = true; this.renderer.finale(); } } render_time() { if (this.check_ended()) { return; } this.renderer.render_time( Math.floor((this.end_at - new Date()) / 1000) ); } render() { if (this.ended) { return; } // Render goal if (this.next_goal != null) { this.renderer.render_current_goal( this.next_goal.name, this.total_contribution, this.next_goal.required, ); this.renderer.animate(); } else if (this.last_goal != null) { this.renderer.render_current_goal( this.last_goal.name, this.total_contribution, this.last_goal.required, ); this.renderer.animate(); } else { this.renderer.clear_current_goal(); } // Render leaderboard this.renderer.render_users(this.leaderboard); this.renderer.animate(); // Render timer this.render_time(); this.set_ended(); } /** * 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") this.goal_namedupe = document.getElementById("GoalNameDupe") // 1524/3000 Points this.goal_progress = document.getElementById("GoalProgress") this.goal_bar = document.getElementById("GoalProgressBar") // Top 3 Gifters this.topusers_label = document.getElementById("TopUsers") // Leaderboard items // 1. (or points?) (1 as in 1st place, static) this.topusers_user1_num = document.getElementById("GiftUserNum1") // Name of user 1 this.topusers_user1_name = document.getElementById("GiftUserName1") this.topusers_userdupe1_name = document.getElementById("GiftUserNameDupe1") this.topusers_user1_score = document.getElementById("GiftUserScore1") this.topusers_user2_num = document.getElementById("GiftUserNum2") this.topusers_user2_name = document.getElementById("GiftUserName2") this.topusers_userdupe2_name = document.getElementById("GiftUserNameDupe2") this.topusers_user2_score = document.getElementById("GiftUserScore2") this.topusers_user3_num = document.getElementById("GiftUserNum3") this.topusers_user3_name = document.getElementById("GiftUserName3") this.topusers_userdupe3_name = document.getElementById("GiftUserNameDupe3") this.topusers_user3_score = document.getElementById("GiftUserScore3") // -- Animation -- // Left-fade wrappers this.scrollwraps = document.getElementsByClassName("scrollWrap") // Parent wrappers this.scrollboxes = [document.getElementById("GiftUserRow1"), document.getElementById("GiftUserRow2"), document.getElementById("GiftUserRow3"), document.getElementById("ScrollWrapGoal")] // Leaderboard names this.userboxes = [this.topusers_user1_name, this.topusers_user2_name, this.topusers_user3_name] this.userdupeboxes = [this.topusers_userdupe1_name, this.topusers_userdupe2_name, this.topusers_userdupe3_name] // Thank you! this.thankyou = "Thank you! The subathon has now ended, thank you so much for contributions and viewership to celebrate!" } // -- Toggles animation if text overflows container and adjusts speed for uniform movement -- animate() { for (let i = 0; i < this.scrollboxes.length; i++) { // Check whether the itteration is a username or goal if (this.scrollboxes[i].id == 'ScrollWrapGoal') { // Check if element text overflows if (this.goal_name.getBoundingClientRect().width > (this.scrollboxes[i].getBoundingClientRect().width)) { // Toggle animation this.goal_name.classList.add("scrollAnim"); this.goal_namedupe.classList.add("scrollAnim"); this.goal_namedupe.style.display = 'inline-block'; this.scrollwraps[i].classList.add("fadeLeft"); // Set animation speed this.goal_name.style.animationDuration = `${this.goal_name.getBoundingClientRect().width / 40}s`; this.goal_namedupe.style.animationDuration = `${this.goal_name.getBoundingClientRect().width / 40}s`; } else { // Toggle animation this.goal_name.classList.remove("scrollAnim"); this.goal_namedupe.classList.remove("scrollAnim"); this.goal_namedupe.style.display = 'none'; this.scrollwraps[i].classList.remove("fadeLeft"); } } else { // Check if element text overflows if (this.userboxes[i].getBoundingClientRect().width > (this.scrollboxes[i].getBoundingClientRect().width * .65)) { // Toggle animation this.userboxes[i].classList.add("scrollAnim"); this.userdupeboxes[i].classList.add("scrollAnim"); this.userdupeboxes[i].style.display = 'inline-block'; this.scrollwraps[i].classList.add("fadeLeft"); // Set animation speed this.userboxes[i].style.animationDuration = `${this.userboxes[i].getBoundingClientRect().width / 40}s`; this.userdupeboxes[i].style.animationDuration = `${this.userboxes[i].getBoundingClientRect().width / 40}s`; } else { // Toggle animation this.userboxes[i].classList.remove("scrollAnim"); this.userdupeboxes[i].classList.remove("scrollAnim"); this.userdupeboxes[i].style.display = 'none'; this.scrollwraps[i].classList.remove("fadeLeft"); } } } } /** * Bring the display into a standard initial state. */ reset() { this.timer.textContent = "88:88"; this.goal_label.textContent = "Next Goal:"; this.goal_name.textContent = "Goal name for the subathon"; this.goal_namedupe.textContent = "Goal name for the subathon"; this.goal_progress.textContent = "1234/1234"; this.topusers_label.textContent = "Leaderboard:"; this.topusers_user1_num.textContent = "1"; this.topusers_user1_name.textContent = "Username1024"; this.topusers_userdupe1_name.textContent = "Username1024"; this.topusers_user1_score.textContent = "0000"; this.topusers_user2_num.textContent = "2"; this.topusers_user2_name.textContent = "User"; this.topusers_userdupe2_name.textContent = "User"; this.topusers_user2_score.textContent = "0000"; this.topusers_user3_num.textContent = "3"; this.topusers_user3_name.textContent = "Username_Very_Super_Long12"; this.topusers_userdupe3_name.textContent = "Username_Very_Super_Long12"; this.topusers_user3_score.textContent = "0000"; } // End subathon, change timer color, and show thank you message finale() { this.timer.textContent = "00:00"; this.timer.style.color = '#82C8B6'; this.goal_label.textContent = "Completed!"; this.goal_name.textContent = this.thankyou; this.goal_namedupe.textContent = this.thankyou; this.animate(); } /** * 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; this.goal_namedupe.textContent = name; //this.goal_name.style.animationDuration = `${this.goal_name.getBoundingClientRect().width / 50}s`; // Bitwise OR done to convert floats to integers if needed this.goal_progress.textContent = String(current | 0) + '/' + String(required | 0) // Update the progress bar if (current / required > .02) { this.goal_bar.style.maskImage = `linear-gradient(to right, #FC98B3 0%, #FC98B3 ${((current / required) - .02) * 100}%, transparent ${(current / required) * 100}%)`; } else { this.goal_bar.style.maskImage = `linear-gradient(to right, #FC98B3 0%, transparent .01%)`; } } /** * Clear the current goal field. */ clear_current_goal() { this.goal_name.textContent = ""; //this.goal_namedupe.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; this.topusers_userdupe1_name.textContent = users[0].user_name; this.topusers_user1_score.textContent = users[0].amount; //this.topusers_user1_name.style.animationDuration = `${this.topusers_user1_name.getBoundingClientRect().width / 50}s`; } else { this.topusers_user1_num.textContent = "1"; this.topusers_user1_name.textContent = ""; this.topusers_userdupe1_name.textContent = ""; this.topusers_user1_score.textContent = "0000"; } if (users.length >= 2) { this.topusers_user2_num.textContent = "2"; this.topusers_user2_name.textContent = users[1].user_name; this.topusers_userdupe2_name.textContent = users[1].user_name; this.topusers_user2_score.textContent = users[1].amount; //this.topusers_user2_name.style.animationDuration = `${this.topusers_user2_name.getBoundingClientRect().width / 50}s`; } else { this.topusers_user2_num.textContent = "2"; this.topusers_user2_name.textContent = ""; this.topusers_userdupe2_name.textContent = ""; this.topusers_user2_score.textContent = "0000"; } if (users.length >= 3) { this.topusers_user3_num.textContent = "3"; this.topusers_user3_name.textContent = users[2].user_name; this.topusers_userdupe3_name.textContent = users[2].user_name; this.topusers_user3_score.textContent = users[2].amount; //this.topusers_user3_name.style.animationDuration = `${this.topusers_user3_name.getBoundingClientRect().width / 50}s`; } else { this.topusers_user3_num.textContent = "3"; this.topusers_user3_name.textContent = ""; this.topusers_userdupe3_name.textContent = ""; this.topusers_user3_score.textContent = "0000"; } } } 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(); renderer.animate(); }; 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(); renderer.animate(); 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}.`); } }; }