forked from HoloTech/twitch-subathon-timer
501 lines
16 KiB
JavaScript
501 lines
16 KiB
JavaScript
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}.`);
|
|
}
|
|
};
|
|
}
|