From c79f969059a02b75aa5eda853bc384e9280fcfe7 Mon Sep 17 00:00:00 2001 From: Interitio Date: Fri, 31 Oct 2025 23:50:35 +1000 Subject: [PATCH] Script for new UI and payload format. --- sample-payload.json | 144 ++++++++++++++++++ timer.js | 359 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 439 insertions(+), 64 deletions(-) create mode 100644 sample-payload.json diff --git a/sample-payload.json b/sample-payload.json new file mode 100644 index 0000000..221a205 --- /dev/null +++ b/sample-payload.json @@ -0,0 +1,144 @@ +{ + "type": "DO", + "method": "setTimer", + "args": { + "name": "Subathon", + "end_at": "2025-10-30T19:34:05.867637+00:00", + "is_running": false, + "score_table": { + "bit_score": 0.1, + "t1_score": 5.0, + "t2_score": 10.0, + "t3_score": 20.0, + "score_time": 10 + }, + "total_contribution": 120.0, + "total_subpoints": 24, + "total_bits": 0, + "goals_met": 4, + "goals_total": 16, + "last_goal": { + "required": 100.0, + "name": "reveal new sub and bit badges" + }, + "next_goal": { + "required": 150.0, + "name": "discord watch party + puzzles" + }, + "goals": [ + { + "required": 25.0, + "name": "merch reveal! (drops on nov 6 anniversary)" + }, + { + "required": 50.0, + "name": "chat picks my pfp for a week" + }, + { + "required": 75.0, + "name": "make subathon magma board" + }, + { + "required": 100.0, + "name": "reveal new sub and bit badges" + }, + { + "required": 150.0, + "name": "discord watch party + puzzles" + }, + { + "required": 200.0, + "name": "new emotes reveal (static)" + }, + { + "required": 300.0, + "name": "karaoke time" + }, + { + "required": 400.0, + "name": "voicemod on for an hour" + }, + { + "required": 500.0, + "name": "new emotes reveal (animated)" + }, + { + "required": 750.0, + "name": "chat picks a skeb to get" + }, + { + "required": 1000.0, + "name": "chat picks new emote i will draw" + }, + { + "required": 1250.0, + "name": "play peak with mods" + }, + { + "required": 1500.0, + "name": "read voicelines/copypastas/memes that chat picks" + }, + { + "required": 1750.0, + "name": "add bald merch to merch drop" + }, + { + "required": 2000.0, + "name": "play horror game on stream (chat picks)" + }, + { + "required": 3000.0, + "name": "upload a song cover to youtube" + } + ], + "last_contribution": { + "user_name": null, + "user_id": null, + "amount": 25.0, + "seconds_added": 250, + "timestamp": "2025-10-30T22:43:53.786952+10:00" + }, + "leaderboard": [ + { + "user_name": "Unknown", + "user_id": 4, + "amount": 25.0 + }, + { + "user_name": "Maza1862", + "user_id": 5, + "amount": 25.0 + }, + { + "user_name": "Unknown", + "user_id": 6, + "amount": 25.0 + }, + { + "user_name": "lalzan", + "user_id": 9, + "amount": 25.0 + }, + { + "user_name": "DesmondGTX1", + "user_id": 10, + "amount": 5.0 + }, + { + "user_name": "SokuReload2", + "user_id": 11, + "amount": 5.0 + }, + { + "user_name": "Unknown", + "user_id": 12, + "amount": 5.0 + }, + { + "user_name": "Sum1wiserthanu", + "user_id": 13, + "amount": 5.0 + } + ] + } +} diff --git a/timer.js b/timer.js index 2700b42..e93c379 100644 --- a/timer.js +++ b/timer.js @@ -1,36 +1,147 @@ window.addEventListener("DOMContentLoaded", () => { - const websocket = new WebSocket("wss://lilac.thewisewolf.dev/tracker/timer/ws"); - - websocket.addEventListener("open", () => { - communicate(websocket); - }); - + const websocket = new ReconnectingWebSocket("wss://lilac.thewisewolf.dev/tracker/timer/ws"); + communicate(websocket); }); class Timer { - constructor(renderer, end_time, running) { + constructor(renderer, timer_data) { this.renderer = renderer; - this.end_time = end_time; - this.running = running; - 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() { - // Render timer status to the document - var timestr; - // How many seconds to the end of time - var dur_seconds = Math.floor( (this.end_time - new Date()) / 1000); + this.renderer.render_time( + Math.floor((this.end_at - new Date()) / 1000) + ); + } - if (dur_seconds < 0) { + 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 = dur_seconds % 60; - dur_seconds = dur_seconds - seconds; - var minutes = Math.floor(dur_seconds / 60); + 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'); @@ -38,59 +149,180 @@ class Timer { timestr = String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0'); } } - - this.renderer.update_time(timestr); + this.timer.textContent = timestr; } - tick() { - // Recursive call run per second to update and change stage + /** + * 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 - if (this.destroying) { - return + 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 = ""; } - this.render_time(); - if (this.running) { - setTimeout(this.tick.bind(this), 1000); + + 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'); } } } -class TimerRenderer { - constructor (){ - this.background = document.getElementsByClassName("timer-bg")[0]; - this.time_element = document.getElementsByClassName("timer-time")[0]; - this.bg_width = parseInt( - window.getComputedStyle( - this.background, null - ).getPropertyValue('width'), - 10 - ); - this.bg_height = parseInt( - window.getComputedStyle( - this.background, null - ).getPropertyValue('height'), - 10 - ) - } - - update_time (timestr) { - this.time_element.textContent = timestr; - } - -} function communicate(websocket) { console.log("Communicating"); const params = new URLSearchParams(window.location.search); - websocket.send(JSON.stringify({type: "init", channel: "SubTimer", community: params.get('community')})); - var renderer = new TimerRenderer(); let timer; - websocket.addEventListener("message", ({ data }) => { - console.log("Rec Event " + data); + 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": @@ -99,25 +331,24 @@ function communicate(websocket) { // Call the specified method switch (event.method) { case "setTimer": - if (timer != null) { - timer.destroying = true; + if (timer == null) { + timer = new Timer(renderer, args); + } else { + timer.update_from_data(args); } - timer = new Timer( - renderer, - new Date(args.end_at), - args.running - ) + timer.render(); timer.tick(); break; case "noTimer": if (timer != null) { - timer.renderer.update_time("--:--") timer.destroying = true; + timer = null; } + renderer.reset(); case "endTimer": if (timer != null) { - timer.renderer.update_time("00:00") timer.destroying = true; + timer = null; } default: throw new Error(`Unsupported method requested: ${event.method}.`) @@ -126,5 +357,5 @@ function communicate(websocket) { default: throw new Error(`Unsupported event type: ${event.type}.`); } - }); + }; }