22 Commits

Author SHA1 Message Date
1a09ea3d80 -decimals
- round decimals on leaderboard
2025-11-09 02:26:57 -05:00
ff7c48dab4 -leaderboard scores
- will not work over 1000 points
2025-11-06 03:42:41 -05:00
6c2b897522 -red progress bar 2025-11-05 23:18:56 -05:00
192f3c0eb1 - cleanup 2025-11-04 12:19:37 -05:00
8e95f378d7 - OBS progress bar bugfix 2025-11-04 12:18:33 -05:00
f2fc02ff7e v5-Progress Bar, Finale
- added progress bar and 00:00 timer color change (first included in hot garbage)

- cleanup of local testing code

- adjustments to finale/stop logic to ensure final update when the clock stops

- minor polish

- ready for production!!! (I hope)
2025-11-03 15:57:11 -05:00
75cdfcf09a Add timer end checking 2025-11-03 22:20:10 +10:00
11ca4470fe -hot garbage
- never send this version live
2025-11-03 06:58:14 -05:00
2d9e320d42 v4.1-Spacing adjustments
- relaxed vertical spacing and increased horizontal gap between text and graphic

- cleanup
2025-11-02 04:45:52 -05:00
da6ae0b9c3 v4-Animation rework
- added JS logic for controlling animations based on text length

- added duplicate elements for looping scroll effect and JS logic for populating them
2025-11-02 03:45:11 -05:00
960c037362 Completed v3
- layout change, reorganized elements and duplicated graphic to avoid using z-index which did not function in OBS browser sources - DO NOT REMOVE DUPLICATE HEART GRAPHIC -

- animation!!! + more wrappers to split up static vs dynamic effects

- temp legacy versions for easier comparisons (no-bold and uniform-font html+css)

- js content tweaks to maintain parity with layout template
2025-10-31 18:50:34 -04:00
1f49e82035 Merge branch 'v2-Prototyping' of https://git.thewisewolf.dev/CarmiCoven/subathon-timer into v2-Prototyping 2025-10-31 11:06:37 -04:00
31bc26c72f Completed v2
- aligned elements to graphic

- minimized wasted space (lots of small manual adjustments)

- extra wrappers for better formatting

- tested various text lengths to ensure static elements sizing
2025-10-31 10:43:14 -04:00
45502c00e4 Merge branch 'master' into v2-Prototyping 2025-10-31 23:53:35 +10:00
c79f969059 Script for new UI and payload format. 2025-10-31 23:50:35 +10:00
9544579ebe Quick-push checkpoint
- messy, under renovations

- IDs for important content (timer, goal progress, username elements) should be final
2025-10-31 07:34:08 -04:00
f8bce7a50c Scaling method and structure refactor
- Unify scaling method based on image height instead of individual elements for better iteration control.

- Restructure layout method to reduce reliance on manual grid definitions, remove unnecessary grid blocks, and improve dynamic layout for simpler iteration

- Breaks layout, needs new polish pass
2025-10-31 05:48:40 -04:00
b1fb0299d2 v2-Init
Create branch and rollback point for v2 prototyping
2025-10-31 04:15:21 -04:00
70c0b3984c Add UI layout and placeholder graphic
HTML:
- doc structure
- static placeholder content
- add fonts (VT323, Silkscreen)

CSS:
- full v1 styling
- dynamically scales content based on image size
- clean cutoff of overflow text for usernames and goal names
2025-10-30 13:55:12 -04:00
47218deb87 Change font to VT323 2025-10-30 23:48:42 +10:00
df6ce48681 Update name 2025-10-30 19:42:30 +10:00
90c8774011 Update websocket URL 2025-10-30 19:20:39 +10:00
7 changed files with 1015 additions and 87 deletions

View File

@@ -2,16 +2,132 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&family=VT323&display=swap" rel="stylesheet">
<link href='https://fonts.googleapis.com/css?family=Inter' rel='stylesheet'> <link href='https://fonts.googleapis.com/css?family=Inter' rel='stylesheet'>
<link rel="stylesheet" href="timer.css" /> <link rel="stylesheet" href="timer.css" />
<script src="timer.js"></script> <script src="timer.js"></script>
<title>Croccy Timer</title> <title>Lilac Timer</title>
</head> </head>
<body> <body>
<div class="timer-box"> <main>
<div class="timer-time"> <div class='contentContainer'>
00:00
<img class='mainHeart' src='src/youvegotmail.webp'>
<div class='infoWrap'>
<div class='infoGroup'>
<div class='infoBox' id='InfoBox3'>
<p id='TopUsers'>
Leaderboard:
</p>
<div class='rightFadeWrap' id='RightFadeWrapInfoBox3'>
<div class='giftUserWrap' id='GiftUserWrap'>
<div class='giftUserRow' id='GiftUserRow1'>
<p class='giftUserNum' id='GiftUserNum1'>
1
</p>
<div class='scrollWrap' id='ScrollWrapName1'>
<div class='scrollWrapW' id='ScrollWrapNameW1'>
<p class='giftUserName'id='GiftUserName1'>
Usernamethatisverylong
</p>
<p class='giftUserNameDupe'id='GiftUserNameDupe1'>
Usernamethatisverylong
</p>
</div>
</div>
<p class='giftUserScore' id='GiftUserScore1'>
0000
</p>
</div>
<div class='giftUserRow' id='GiftUserRow2'>
<p class='giftUserNum' id='GiftUserNum2'>
2
</p>
<div class='scrollWrap' id='ScrollWrapName2'>
<div class='scrollWrapW' id='ScrollWrapNameW2'>
<p class='giftUserName'id='GiftUserName2'>
Usernamethatisverylong
</p>
<p class='giftUserNameDupe'id='GiftUserNameDupe2'>
Usernamethatisverylong
</p>
</div>
</div>
<p class='giftUserScore' id='GiftUserScore2'>
25
</p>
</div>
<div class='giftUserRow' id='GiftUserRow3'>
<p class='giftUserNum' id='GiftUserNum3'>
3
</p>
<div class='scrollWrap' id='ScrollWrapName3'>
<div class='scrollWrapW' id='ScrollWrapNameW3'>
<p class='giftUserName'id='GiftUserName3'>
Usernamethatisverylong
</p>
<p class='giftUserNameDupe'id='GiftUserNameDupe3'>
Usernamethatisverylong
</p>
</div>
</div>
<p class='giftUserScore' id='GiftUserScore3'>
1
</p>
</div>
</div>
</div>
</div>
<img class='mainHeart' id='SecondHeart' src='src/youvegotmail.webp'>
<div class='infoBox' id='InfoBox1'>
<h1 id='Timer'>
00:00
</h1>
<p id='TimerInfo'>
!subathon for details
</p>
</div>
<div class='infoBox' id='InfoBox2'>
<p id='GoalLabel'>
Next Goal:
</p>
<div class='rightFadeWrap' id='RightFadeWrapInfoBox2'>
<div class='scrollWrap' id='ScrollWrapGoal'>
<div class='scrollWrapW' id='ScrollWrapGoalW'>
<p id='GoalName'>
Discord Watch Party + Puzzles
</p>
<p id='GoalNameDupe'>
Discord Watch Party + Puzzles
</p>
</div>
</div>
</div>
<div id='GoalProgressWrap'>
<div id='GoalProgressBar'>
<p id='GoalProgressTransparent' style='color: transparent;'>
1234/5678
</p>
</div>
<p id='GoalProgress'>
1234/5678
</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</main>
</body> </body>
</html> </html>

144
sample-payload.json Normal file
View File

@@ -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
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

BIN
src/youvegotmail.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

332
timer.css
View File

@@ -4,33 +4,331 @@
--background-color: transparent; --background-color: transparent;
} }
* {
box-sizing: border-box;
}
html, body { html, body {
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
background-color: var(--background-color); background-color: var(--background-color);
background-color: transparent;
overflow: hidden; overflow: hidden;
} }
main {
.timer-box { font-family: "Silkscreen", sans-serif;
height: 175px;
width: 397px;
padding: 5px;
}
.timer-time {
font-size: clamp(6rem, 22vw, 16rem);
font-weight: 400; font-weight: 400;
color: var(--time-color); font-style: normal;
line-height: 1; color: #EA4045;
height: 100%;
} }
.timer-details { h1 {
display: fill; margin: 0;
font-weight: 600; font-size: 3.6em;
color: var(--details-color); color: #404145;
font-size: 16px; font-weight: normal;
overflow-wrap: break-word;
} }
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%;}
}

498
timer.js
View File

@@ -1,36 +1,259 @@
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
const websocket = new WebSocket("wss://izashi.thewisewolf.dev/tracker/timer/ws"); const websocket = new ReconnectingWebSocket("wss://lilac.thewisewolf.dev/tracker/timer/ws");
communicate(websocket);
websocket.addEventListener("open", () => {
communicate(websocket);
});
}); });
class Timer { class Timer {
constructor(renderer, end_time, running) { constructor(renderer, timer_data) {
this.renderer = renderer; this.renderer = renderer;
this.end_time = end_time;
this.running = running;
this.destroying = false; 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() { render_time() {
// Render timer status to the document if (this.check_ended()) {
var timestr; return;
// 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() {
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"; timestr = "00:00";
} else { } else {
var seconds = dur_seconds % 60; var seconds = seconds_remaining % 60;
dur_seconds = dur_seconds - seconds; seconds_remaining = seconds_remaining - seconds;
var minutes = Math.floor(dur_seconds / 60); var minutes = Math.floor(seconds_remaining / 60);
var hours = Math.floor(minutes / 60); var hours = Math.floor(minutes / 60);
minutes = minutes - 60 * hours; minutes = minutes - 60 * hours;
// Change rendering mode based on hours or minutes left // Change rendering mode based on hours or minutes left
if (hours > 0) { if (hours > 0) {
timestr = String(hours).padStart(2, '0') + ':' + String(minutes).padStart(2, '0'); timestr = String(hours).padStart(2, '0') + ':' + String(minutes).padStart(2, '0');
@@ -38,59 +261,206 @@ class Timer {
timestr = String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0'); timestr = String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0');
} }
} }
this.timer.textContent = timestr;
this.renderer.update_time(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) { this.goal_name.textContent = name;
return 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%)`;
} }
this.render_time(); }
if (this.running) {
setTimeout(this.tick.bind(this), 1000); /**
* 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');
} }
} }
} }
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) { function communicate(websocket) {
console.log("Communicating"); console.log("Communicating");
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
websocket.send(JSON.stringify({type: "init", channel: "SubTimer", community: params.get('community')}));
var renderer = new TimerRenderer(); var renderer = new TimerRenderer();
let timer; let timer;
websocket.addEventListener("message", ({ data }) => { websocket.onopen = () => {
console.log("Rec Event " + data); websocket.send(
JSON.stringify(
{
type: "init",
channel: "SubTimer",
community: params.get('community')
}
)
);
renderer.reset();
renderer.animate();
};
websocket.onmessage = ({ data }) => {
const event = JSON.parse(data); const event = JSON.parse(data);
switch (event.type) { switch (event.type) {
case "DO": case "DO":
@@ -99,25 +469,25 @@ function communicate(websocket) {
// Call the specified method // Call the specified method
switch (event.method) { switch (event.method) {
case "setTimer": case "setTimer":
if (timer != null) { if (timer == null) {
timer.destroying = true; timer = new Timer(renderer, args);
} else {
timer.update_from_data(args);
} }
timer = new Timer( timer.render();
renderer,
new Date(args.end_at),
args.running
)
timer.tick(); timer.tick();
break; break;
case "noTimer": case "noTimer":
if (timer != null) { if (timer != null) {
timer.renderer.update_time("--:--")
timer.destroying = true; timer.destroying = true;
timer = null;
} }
renderer.reset();
renderer.animate();
case "endTimer": case "endTimer":
if (timer != null) { if (timer != null) {
timer.renderer.update_time("00:00")
timer.destroying = true; timer.destroying = true;
timer = null;
} }
default: default:
throw new Error(`Unsupported method requested: ${event.method}.`) throw new Error(`Unsupported method requested: ${event.method}.`)
@@ -126,5 +496,5 @@ function communicate(websocket) {
default: default:
throw new Error(`Unsupported event type: ${event.type}.`); throw new Error(`Unsupported event type: ${event.type}.`);
} }
}); };
} }