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; //TEMP this.temptime = 15; 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( //TEMP this.temptime //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, ); this.renderer.animate(); } else if (this.last_goal != null) { this.renderer.render_current_goal( this.next_goal.name, this.total_contribution, this.next_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(); } /** * 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); //if (Math.floor((this.end_at - new Date()) / 1000) > 0) { if (Math.floor(this.temptime) > 0) { //TEMP this.temptime = this.temptime - 1; setTimeout(this.tick.bind(this), 1000); } else { } } } } } 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_user2_num = document.getElementById("GiftUserNum2") this.topusers_user2_name = document.getElementById("GiftUserName2") this.topusers_userdupe2_name = document.getElementById("GiftUserNameDupe2") this.topusers_user3_num = document.getElementById("GiftUserNum3") this.topusers_user3_name = document.getElementById("GiftUserName3") this.topusers_userdupe3_name = document.getElementById("GiftUserNameDupe3") // -- 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 very super lots many ultra bunches large big huge mega galactic universal multiversal infinite thnaks!" } // -- Toggles animation if text overflows container and adjusts speed for uniform movement -- animate() { for (let i = 0; i < this.scrollboxes.length; i++) { console.log('animate'); // 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 * .85) ) { // 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 * .75) ) { // 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 = "07:13"; this.goal_label.textContent = "Next Goal:"; this.goal_name.textContent = "The answer is..."; this.goal_namedupe.textContent = "The answer is..."; this.goal_progress.textContent = "1234/5678"; 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_user2_num.textContent = "2"; this.topusers_user2_name.textContent = "User"; this.topusers_userdupe2_name.textContent = "User"; this.topusers_user3_num.textContent = "3"; this.topusers_user3_name.textContent = "Username_Very_Super_Long12"; this.topusers_userdupe3_name.textContent = "Username_Very_Super_Long12"; console.log('reset'); } /** * Render the timer from a time remaining. * * @param {integer} seconds_remaining - The number of seconds remaining on the timer. */ render_time(seconds_remaining) { console.log(seconds_remaining); var timestr; // Render a time remaining given in seconds if (seconds_remaining <= 0) { timestr = "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(); } 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; console.log('hr' + hours); console.log('mn' + minutes); console.log('sc' + seconds); // 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) console.log('ratio' + current/required); if (current/required > .02) { console.log('%' + (current/required) * 100); this.goal_bar.style.maskImage = `linear-gradient(to right, #55CBFF 0%, #55CBFF ${((current/required) - .02)*100}%, transparent ${(current/required)*100}%)`; } else { this.goal_bar.style.maskImage = `linear-gradient(to right, transparent 0%)`; } } /** * 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_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 = ""; } 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_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 = ""; } 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_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 = ""; } } } 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}.`); } }; }