diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..76c257b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+i_*
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..884b3e4
--- /dev/null
+++ b/index.html
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+ Lilac Timer
+
+
+
+
+
+

+
+
+
+
+
+
+

+
+
+
+ 00:00
+
+
+ !subathon for details
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
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.css b/timer.css
new file mode 100644
index 0000000..0ab19d5
--- /dev/null
+++ b/timer.css
@@ -0,0 +1,334 @@
+:root {
+ --time-color: #453960;
+ --details-color: #403060;
+ --background-color: transparent;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html, body {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ font-family: 'Inter', sans-serif;
+ background-color: var(--background-color);
+ background-color: transparent;
+ overflow: hidden;
+}
+
+main {
+ font-family: "Silkscreen", sans-serif;
+ font-weight: 400;
+ font-style: normal;
+ color: #EA4045;
+ height: 100%;
+}
+
+h1 {
+ margin: 0;
+ font-size: 3.6em;
+ color: #404145;
+ font-weight: normal;
+}
+
+h2 {
+ margin: 0;
+ font-size: 1.4em;
+ color: #404145;
+ font-weight: normal;
+}
+
+h3 {
+ margin: 0;
+ font-size: 1em;
+ color: #404145;
+ font-weight: normal;
+}
+
+p {
+ margin: 0;
+ font-size: 1.3em;
+}
+
+.contentContainer {
+ height: fit-content;
+ width: fit-content;
+ position: relative;
+ text-align: center;
+ }
+
+.mainHeart {
+ max-height: 100vh;
+ max-width: 72vw;
+ }
+
+ #SecondHeart {
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ }
+
+.infoWrap {
+ container-type: size;
+ height: 100%;
+ width: 100%;
+ position: absolute;
+ left: 0px;
+ top: 0px;
+ display: grid;
+ grid-template-columns: 56% 34% 1fr;
+ grid-template-rows: 11% 1fr 29%;
+ place-items: center;
+ text-wrap: nowrap;
+}
+
+.infoGroup {
+ height: 100%;
+ width: 100%;
+ grid-column-start: 2;
+ grid-column-end: 2;
+ grid-row-start: 2;
+ grid-row-end: 2;
+}
+
+.infoBox {
+ height: auto;
+ width: 100%;
+ font-size: 4cqh;
+}
+
+#InfoBox1 {
+ transform: translate(-2%, 0%);
+ margin-top: -50%;
+}
+
+#InfoBox2 {
+ /* -- !!DO NOT REMOVE!! --
+ Removing this transform kills the goal label and I do not know why
+ */
+ transform: translate(0%, 0%);
+}
+
+#InfoBox3 {
+ width: 142%;
+ position: relative;
+ transform: translate(65%, 10%) scale(1, 1);
+ border-radius: 3cqh;
+ border: .4cqh solid;
+ border-color: #00A0F3;
+ background-color: white;
+ padding: .4ch 0 .4ch 0;
+}
+
+#Timer {
+ /*
+ margin-top: 10%;
+ margin-bottom: 2%;
+ */
+ transform: translate(0%, 0%) scale(.95, 1.2);
+}
+
+#TimerInfo {
+ transform: translate(0%, -60%);
+ display: none;
+}
+
+#GoalLabel {
+ color: #404145;
+}
+
+#GoalName {
+ width: fit-content;
+ position: relative;
+ color: #32B993;
+ text-wrap: nowrap;
+ display: inline-block;
+ padding-right: 4%;
+ transform: translate(.5ch, 0%);
+ /* --- animation-duration = JS VAR --- */
+}
+
+#GoalNameDupe {
+ width: fit-content;
+ position: relative;
+ color: #32B993;
+ text-wrap: nowrap;
+ padding-right: 4%;
+ transform: translate(.5ch, 0%);
+ /* --- animation-duration = JS VAR --- */
+}
+
+#GoalProgressWrap {
+ width: fit-content;
+ padding-left: .8cqh;
+ padding-right: .8cqh;
+ border-radius: 3cqh;
+ border: .4cqh solid;
+ border-color: #404145;
+ margin: .4ch auto 0 auto;
+ overflow: hidden;
+}
+
+#GoalProgressBar {
+ width: 110%;
+ background-color: #FC98B3;
+ transform: translate(-5%, 0%) scale(1, 1.5);
+}
+
+#GoalProgress {
+ transform: translate(0%, -8%) scale(1);
+ text-align: center;
+ margin-top: -19%;
+ color: #404145;
+}
+
+#TopUsers {
+ margin-bottom: 2%;
+ font-weight: normal;
+ color: #404145;
+}
+
+.giftUserWrap {
+ transform: translate(9%, -8%) scale(1, 1);
+}
+
+.giftUserRow {
+ width: 100%;
+ display: grid;
+ grid-template-columns: 2ch 14ch 5ch;
+ grid-template-rows: 1fr;
+ text-wrap: nowrap;
+ overflow: hidden;
+ text-align: center;
+}
+
+.giftUserNum {
+ border-right: 1.5px solid #00A0F3;
+ border-image: linear-gradient(to top,
+ transparent 0%,
+ transparent 5%,
+ #00A0F3 35%,
+ #00A0F3 65%,
+ transparent 95%) 1;
+}
+
+.giftUserScore {
+ border-left: 1.5px solid #00A0F3;
+ border-image: linear-gradient(to top,
+ transparent 0%,
+ transparent 5%,
+ #00A0F3 40%,
+ #00A0F3 60%,
+ transparent 95%) 1;
+ text-align: right;
+ padding-right: .25ch;
+}
+
+.giftUserName {
+ width: fit-content;
+ margin-left: 0%;
+ position: relative;
+ color: #32B993;
+ text-align: left;
+ display: inline-block;
+ padding-right: 4%;
+ transform: translate(.5ch, 0%);
+}
+
+.giftUserNameDupe {
+ width: fit-content;
+ margin-left: 0%;
+ position: relative;
+ color: #32B993;
+ text-align: left;
+ padding-right: 4%;
+ transform: translate(.5ch, 0%);
+}
+
+#GiftUserName1 {
+ /* --- animation-duration = JS VAR --- */
+}
+
+#GiftUserName2 {
+ /* --- animation-duration = JS VAR --- */
+}
+
+#GiftUserName3 {
+ /* --- animation-duration = JS VAR --- */
+}
+
+#GiftUserNameDupe1 {
+ /* --- animation-duration = JS VAR --- */
+}
+
+#GiftUserNameDupe2 {
+ /* --- animation-duration = JS VAR --- */
+}
+
+#GiftUserNameDupe3 {
+ /* --- animation-duration = JS VAR --- */
+}
+
+.scrollWrap {
+ width: 100%;
+}
+
+.rightFadeWrap {
+ /*
+ mask-image: linear-gradient(to right, black 75%, transparent 90%);
+ */
+}
+
+#RightFadeWrapInfoBox2 {
+ width: 90%;
+ border-left: 1.5px solid #00A0F3;
+ border-right: 1.5px solid #00A0F3;
+ border-image: linear-gradient(to top,
+ transparent 0%,
+ transparent 5%,
+ #00A0F3 40%,
+ #00A0F3 60%,
+ transparent 95%) 1;
+ transform: translate(1ch, 0%) scale(1);
+}
+
+.fadeLeft {
+ mask-image: linear-gradient(to right,
+ transparent 0%,
+ black 1ch,
+ black calc(100% - 2ch),
+ transparent 100%);
+}
+
+#ScrollWrapGoal {
+ /*
+ width: 90%;
+ */
+}
+
+.scrollWrapName {
+}
+
+.scrollWrapW {
+ width: fit-content;
+ display: flex;
+}
+
+#ScrollWrapGoalW {
+}
+
+.hide {
+ display: none;
+}
+
+.scrollAnim {
+ animation: myScroll linear infinite;
+}
+
+@keyframes myScroll {
+ 0% {left: 0%; top: 0%;}
+ 42% {left: 0%; top: 0%;}
+ 100% {left: -54%; top: 0%;}
+}
\ No newline at end of file
diff --git a/timer.js b/timer.js
new file mode 100644
index 0000000..ef76dfb
--- /dev/null
+++ b/timer.js
@@ -0,0 +1,500 @@
+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 = Math.round(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 = Math.round(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 = Math.round(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}.`);
+ }
+ };
+}