Refactored like the entire frontend

This commit is contained in:
Liam 2022-10-28 19:52:47 +01:00
parent 3e56a16b14
commit b89f31a96e
25 changed files with 13916 additions and 2868 deletions

@ -10,6 +10,7 @@ const nextConfig = {
"cdn.scoresaber.com", "cdn.scoresaber.com",
"eu.cdn.beatsaver.com", "eu.cdn.beatsaver.com",
"cdn.fascinated.cc", "cdn.fascinated.cc",
"avatars.akamai.steamstatic.com",
], ],
}, },
}; };

10257
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

@ -12,6 +12,7 @@
"@emotion/cache": "^11.10.5", "@emotion/cache": "^11.10.5",
"@emotion/server": "^11.10.0", "@emotion/server": "^11.10.0",
"@nextui-org/react": "^1.0.0-beta.10", "@nextui-org/react": "^1.0.0-beta.10",
"axios": "^1.1.3",
"core-js-pure": "^3.26.0", "core-js-pure": "^3.26.0",
"critters": "^0.0.16", "critters": "^0.0.16",
"ioredis": "^5.2.3", "ioredis": "^5.2.3",
@ -24,11 +25,14 @@
"react-country-flag": "^3.0.2", "react-country-flag": "^3.0.2",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-toastify": "^9.0.8", "react-toastify": "^9.0.8",
"sharp": "^0.31.1" "sharp": "^0.31.1",
"websocket": "^1.0.34",
"zustand": "^4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^18.11.7", "@types/node": "^18.11.7",
"@types/react": "^18.0.24", "@types/react": "^18.0.24",
"@types/websocket": "^1.0.5",
"eslint": "8.26.0", "eslint": "8.26.0",
"eslint-config-next": "12.3.1", "eslint-config-next": "12.3.1",
"typescript": "^4.8.4" "typescript": "^4.8.4"

@ -1 +1 @@
window.__ENV = {"REACT_APP_HTTP_PROXY":"https://proxy.fascinated.cc","REACT_APP_SITE_COLOR":"0EBFE9","REACT_APP_SITE_DESCRIPTION":"Free, simple, and easy to use beat saber overlay for OBS","REACT_APP_SITE_NAME":"BeatSaber Overlay","REACT_APP_SITE_TITLE":"BeatSaber Overlay - Simple and easy to use BeatSaber overlay","REACT_APP_SITE_URL":"https://bs-overlay.fascinated.cc"}; window.__ENV = {"REACT_APP_HTTP_PROXY":"https://proxy.fascinated.cc","REACT_APP_SITE_COLOR":"0EBFE9","REACT_APP_SITE_DESCRIPTION":"Free, simple, and easy to use beat saber overlay for OBS","REACT_APP_SITE_NAME":"BeatSaber Overlay","REACT_APP_SITE_TITLE":"BeatSaber Overlay - Simple and easy to use BeatSaber overlay","REACT_APP_SITE_URL":"http://localhost:3000"};

@ -1,35 +1,70 @@
import ReactCountryFlag from "react-country-flag"; import ReactCountryFlag from "react-country-flag";
import { useDataStore } from "../store/overlayDataStore";
import styles from "../styles/playerStats.module.css"; import { useSettingsStore } from "../store/overlaySettingsStore";
import { usePlayerDataStore } from "../store/playerDataStore";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
const PlayerStats = (props) => { import { useSongDataStore } from "../store/songDataStore";
import styles from "../styles/playerStats.module.css";
const PlayerStats = () => {
const [showPlayerStats, shouldReplacePlayerInfoWithScore] = useSettingsStore(
(store) => [store.showPlayerStats, store.shouldReplacePlayerInfoWithScore]
);
const inSong = useSongDataStore((state) => state.inSong);
const [loadedDuringSong] = useDataStore((state) => [state.loadedDuringSong]);
const [leaderboardType] = useSettingsStore((state) => [
state.leaderboardType,
]);
const [isLoading, pp, avatar, globalPos, countryRank, country] =
usePlayerDataStore((state) => [
state.isLoading,
state.pp,
state.avatar,
state.globalPos,
state.countryRank,
state.country,
]);
if (!showPlayerStats) {
return null;
}
// Checks if we are in a song and should replace the player info with song info
if (shouldReplacePlayerInfoWithScore && inSong) {
return null;
}
if (isLoading) {
return <div className={styles.playerStatsContainer}>Loading...</div>;
}
return ( return (
<div className={styles.playerStatsContainer}> <div className={styles.playerStatsContainer}>
<div> <div>
<Avatar url={props.avatar} /> <Avatar url={avatar} />
</div> </div>
<div className={styles.playerStats}> <div className={styles.playerStats}>
<p> <p>
{props.pp}pp{" "} {pp}pp{" "}
<span <span
style={{ style={{
fontSize: "23px", fontSize: "23px",
}} }}
> >
({props.websiteType}) ({leaderboardType})
</span> </span>
</p> </p>
<p>#{props.globalPos}</p> <p>#{globalPos}</p>
<div className={styles.playerCountry}> <div className={styles.playerCountry}>
<p>#{props.countryRank}</p> <p>#{countryRank}</p>
<ReactCountryFlag <ReactCountryFlag
className={styles.playerCountryIcon} className={styles.playerCountryIcon}
svg svg
countryCode={props.country} countryCode={country}
/> />
</div> </div>
{props.loadedDuringSong ? ( {loadedDuringSong ? (
<> <>
<p className={styles.connectedDuringSong}> <p className={styles.connectedDuringSong}>
Connected during song, some data Connected during song, some data

@ -1,60 +1,47 @@
import { Component } from "react"; import { useSettingsStore } from "../store/overlaySettingsStore";
import { useSongDataStore } from "../store/songDataStore";
import styles from "../styles/scoreStats.module.css"; import styles from "../styles/scoreStats.module.css";
import Utils from "../utils/utils";
export default class ScoreStats extends Component { export default function ScoreStats() {
constructor(params) { const [showScoreInfo] = useSettingsStore((store) => [store.showScoreInfo]);
super(params); const [percentage, currentScore, currentPP, saberA, saberB, isLoading] =
this.lastKnownPP = undefined; useSongDataStore((store) => [
store.percentage,
store.currentScore,
store.currentPP,
store.saberA,
store.saberB,
store.isLoading,
]);
if (isLoading) {
return null;
} }
/** if (!showScoreInfo) {
* Returns the average of the provided numbers list return null;
*
* @param {List<Number>} hitValues
* @returns The average value
*/
getAverage(hitValues) {
return hitValues.reduce((p, c) => p + c, 0) / hitValues.length;
} }
render() {
const data = this.props.data;
let currentPP = Utils.calculatePP(
data.mapStarCount,
data.percentage.replace("%", ""),
data.websiteType
);
if (this.lastKnownPP === undefined) {
this.lastKnownPP = currentPP;
}
if (currentPP === undefined) {
currentPP = this.lastKnownPP;
}
this.lastKnownPP = currentPP;
return ( return (
<div className={styles.scoreStats}> <div className={styles.scoreStats}>
<div className={styles.scoreStatsInfo}> <div className={styles.scoreStatsInfo}>
<p>{data.percentage}</p> <p>{percentage}</p>
<p>{data.currentScore.toLocaleString()}</p> <p>{currentScore.toLocaleString()}</p>
{currentPP !== undefined ? <p>{currentPP.toFixed(0)}pp</p> : null} {currentPP !== undefined ? <p>{currentPP.toFixed(0)}pp</p> : null}
</div> </div>
<p className={styles.scoreStatsAverageCut}>Average Cut</p> <p className={styles.scoreStatsAverageCut}>Average Cut</p>
<div className={styles.scoreStatsHands}> <div className={styles.scoreStatsHands}>
<div className={styles.scoreStatsLeft}> <div className={styles.scoreStatsLeft}>
<p>{data.SaberA.averagePreSwing.toFixed(2)}</p> <p>{saberA.averagePreSwing.toFixed(2)}</p>
<p>{data.SaberA.averagePostSwing.toFixed(2)}</p> <p>{saberA.averagePostSwing.toFixed(2)}</p>
<p>{data.SaberA.cutDistanceScore.toFixed(2)}</p> <p>{saberA.cutDistanceScore.toFixed(2)}</p>
</div> </div>
<div className={styles.scoreStatsRight}> <div className={styles.scoreStatsRight}>
<p>{data.SaberB.averagePreSwing.toFixed(2)}</p> <p>{saberB.averagePreSwing.toFixed(2)}</p>
<p>{data.SaberB.averagePostSwing.toFixed(2)}</p> <p>{saberB.averagePostSwing.toFixed(2)}</p>
<p>{data.SaberB.cutDistanceScore.toFixed(2)}</p> <p>{saberB.cutDistanceScore.toFixed(2)}</p>
</div> </div>
</div> </div>
</div> </div>
); );
}
} }

@ -1,80 +1,89 @@
import Image from "next/future/image"; import Image from "next/future/image";
import { Component } from "react"; import { useSettingsStore } from "../store/overlaySettingsStore";
import { useSongDataStore } from "../store/songDataStore";
import styles from "../styles/songInfo.module.css"; import styles from "../styles/songInfo.module.css";
export default class SongInfo extends Component { /**
constructor(params) {
super(params);
this.state = {
diffColor: undefined,
};
}
componentDidMount() {
const data = this.props.data.songData.status.beatmap;
this.formatDiff(data.difficulty);
}
/**
* Update the difficulity color from the given difficulity
*
* @param {string} diff
*/
formatDiff(diff) {
if (diff === "Expert+") {
this.setState({ diffColor: "#8f48db" });
}
if (diff === "Expert") {
this.setState({ diffColor: "#bf2a42" });
}
if (diff === "Hard") {
this.setState({ diffColor: "tomato" });
}
if (diff === "Normal") {
this.setState({ diffColor: "#59b0f4" });
}
if (diff === "Easy") {
this.setState({ diffColor: "MediumSeaGreen" });
}
}
/**
* Format the given ms * Format the given ms
* *
* @param {Number} millis * @param {Number} millis
* @returns The formatted time * @returns The formatted time
*/ */
msToMinSeconds(millis) { function msToMinSeconds(millis) {
const minutes = Math.floor(millis / 60000); const minutes = Math.floor(millis / 60000);
const seconds = Number(((millis % 60000) / 1000).toFixed(0)); const seconds = Number(((millis % 60000) / 1000).toFixed(0));
return seconds === 60 return seconds === 60
? minutes + 1 + ":00" ? minutes + 1 + ":00"
: minutes + ":" + (seconds < 10 ? "0" : "") + seconds; : minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
}
/**
* Update the difficulity color from the given difficulity
*
* @param {string} diff
*/
function formatDiff(diff) {
if (diff === "ExpertPlus") {
return "#8f48db";
}
if (diff === "Expert") {
return "#bf2a42";
}
if (diff === "Hard") {
return "tomato";
}
if (diff === "Normal") {
return "#59b0f4";
}
if (diff === "Easy") {
return "MediumSeaGreen";
}
}
export default function SongInfo() {
const [showSongInfo, shouldReplacePlayerInfoWithScore] = useSettingsStore(
(store) => [store.showSongInfo, store.shouldReplacePlayerInfoWithScore]
);
const [
isLoading,
bsr,
mapArt,
songTitle,
songSubTitle,
songDifficulty,
currentSongTime,
songLength,
] = useSongDataStore((store) => [
store.isLoading,
store.bsr,
store.mapArt,
store.songTitle,
store.songSubTitle,
store.songDifficulty,
store.currentSongTime,
store.songLength,
]);
if (!showSongInfo) {
return null;
} }
render() { if (isLoading) {
const data = this.props.data.songData.status.beatmap; return null;
const beatSaverData = this.props.data.beatSaverData.data; }
const mapArt = beatSaverData.mapArt;
const bsr = beatSaverData.bsr;
const { songName, songAuthorName, difficulty } = data;
const songTimerPercentage =
(this.props.data.currentSongTime / data.length) * 100000;
const cssVars = document.querySelector("." + styles.songInfoContainer); const songTimerPercentage = (currentSongTime / songLength) * 100000;
if (cssVars) { const diffColor = formatDiff(songDifficulty);
if (!this.props.data.isPlayerInfoVisible) {
cssVars.style.setProperty("margin-top", "5px");
cssVars.style.setProperty("--pos", "none");
} else {
cssVars.style.setProperty("margin-top", "0px");
cssVars.style.setProperty("--pos", 0);
}
}
return ( return (
<div className={styles.songInfoContainer}> <div
className={styles.songInfoContainer}
style={{
bottom: shouldReplacePlayerInfoWithScore ? "" : 0,
left: shouldReplacePlayerInfoWithScore ? "" : 0,
}}
>
<Image <Image
width={150} width={150}
height={150} height={150}
@ -86,23 +95,22 @@ export default class SongInfo extends Component {
/> />
<div className={styles.songInfo}> <div className={styles.songInfo}>
<p className={styles.songInfoSongName}> <p className={styles.songInfoSongName}>
{songName.length > 35 {songTitle.length > 35
? songName.substring(0, 35) + "..." ? songTitle.substring(0, 35) + "..."
: songName} : songTitle}
</p> </p>
<p className={styles.songInfoSongAuthor}>{songAuthorName}</p> <p className={styles.songInfoSongSubName}>{songSubTitle}</p>
<div className={styles.songInfoSongOtherContainer}> <div className={styles.songInfoSongOtherContainer}>
<p <p
className={styles.songInfoDiff} className={styles.songInfoDiff}
style={{ backgroundColor: this.state.diffColor }} style={{ backgroundColor: diffColor }}
> >
{difficulty} {songDifficulty.replace("Plus", "+")}
</p> </p>
<p className={styles.songInfoBsr}>!bsr {bsr}</p> <p className={styles.songInfoBsr}>!bsr {bsr}</p>
</div> </div>
<p className={styles.songTimeText}> <p className={styles.songTimeText}>
{this.msToMinSeconds(this.props.data.currentSongTime * 1000)}/ {msToMinSeconds(currentSongTime * 1000)}/{msToMinSeconds(songLength)}
{this.msToMinSeconds(data.length)}
</p> </p>
<div className={styles.songTimeContainer}> <div className={styles.songTimeContainer}>
<div className={styles.songTimeBackground} /> <div className={styles.songTimeBackground} />
@ -114,5 +122,4 @@ export default class SongInfo extends Component {
</div> </div>
</div> </div>
); );
}
} }

@ -1,7 +1,7 @@
import env from "@beam-australia/react-env"; import env from "@beam-australia/react-env";
import { VARS } from "./EnvVars"; import { VARS } from "./EnvVars";
const WebsiteTypes = { const LeaderboardType = {
ScoreSaber: { ScoreSaber: {
ApiUrl: { ApiUrl: {
PlayerData: PlayerData:
@ -33,4 +33,4 @@ const WebsiteTypes = {
}, },
}; };
export default WebsiteTypes; export default LeaderboardType;

@ -0,0 +1,3 @@
export function getMapHashFromLevelId(levelId: string): string {
return levelId.replace("custom_level_", "");
}

@ -0,0 +1,14 @@
import axios from "axios";
import LeaderboardType from "../../consts/LeaderboardType";
export async function getPlayerData(leaderboardType, playerId) {
const data = await axios.get(
LeaderboardType[leaderboardType].ApiUrl.PlayerData.replace("%s", playerId),
{
headers: {
"x-requested-with": "BeatSaber Overlay",
},
}
);
return data;
}

@ -0,0 +1,217 @@
import { w3cwebsocket as W3CWebSocket } from "websocket";
import { useSettingsStore } from "../store/overlaySettingsStore";
import { usePlayerDataStore } from "../store/playerDataStore";
import { useSongDataStore } from "../store/songDataStore";
import Utils from "../utils/utils";
import { getMapHashFromLevelId } from "./map/mapHelpers";
const ip = useSettingsStore.getState().socketAddr;
let hasConnected = false;
let cutData: any = [];
cutData.saberA = {
count: [0, 0, 0],
totalScore: [0, 0, 0],
};
cutData.saberB = {
count: [0, 0, 0],
totalScore: [0, 0, 0],
};
const updatePlayerData = usePlayerDataStore.getState().updatePlayerData;
export function connectClient() {
const client = new W3CWebSocket(`ws://${ip}:6557/socket`);
if (hasConnected) {
return;
}
client.onopen = () => {
console.log("WebSocket Client Connected");
hasConnected = true;
};
client.onclose = () => {
if (hasConnected) {
console.log(
"Lost connection to HTTPSiraStatus, attempting to reconnect."
);
connectClient();
} else {
hasConnected = false;
console.log(
"Unable to connect to HTTPSiraStatus, retrying in 30 seconds."
);
setTimeout(() => {
connectClient();
}, 30_000);
}
};
client.onmessage = (message: any) => {
const data: string = message.data;
const json = JSON.parse(data);
if (!handlers[json.event]) {
console.log("Unhandled message from HTTP Status. (" + json.event + ")");
return;
}
handlers[json.event](json || []);
try {
const time = json.status.performance.currentSongTime;
if (time !== undefined && time != null) {
useSongDataStore.setState({ currentSongTime: time });
}
} catch (e) {}
};
}
const handlers: any = {
hello: (data: any) => {
console.log("Hello from HttpSiraStatus");
if (data.status && data.status.beatmap) {
console.log("Going into level during song, resetting data.");
const state = useSongDataStore.getState();
const {
levelId,
difficultyEnum,
characteristic,
songName,
songSubName,
levelAuthorName,
length,
} = data.status.beatmap;
state.reset();
state.setInSong(true);
state.updateMapData(
getMapHashFromLevelId(levelId),
difficultyEnum,
characteristic,
songName,
songSubName || levelAuthorName,
length
);
const { score, relativeScore } = data.status.performance;
let finalScore = score;
if (finalScore == 0) {
finalScore = state.currentScore;
}
const percent = relativeScore * 100;
state.setCurrentScore(finalScore);
state.setPercent(percent.toFixed(2) + "%");
}
},
songStart: (data: any) => {
console.log("Going into level during song, resetting data.");
const state = useSongDataStore.getState();
const {
levelId,
difficultyEnum,
characteristic,
songName,
songSubName,
length,
} = data.status.beatmap;
state.reset();
state.setInSong(true);
state.updateMapData(
getMapHashFromLevelId(levelId),
difficultyEnum,
characteristic,
songName,
songSubName,
length
);
},
scoreChanged: (data: any) => {
const state = useSongDataStore.getState();
const { status } = data;
const { score, relativeScore } = status.performance;
let finalScore = score;
if (finalScore == 0) {
finalScore = state.currentScore;
}
const percent = relativeScore * 100;
state.setCurrentScore(finalScore);
state.setPercent(percent.toFixed(2) + "%");
const leaderboardType = useSettingsStore.getState().leaderboardType;
let currentPP = Utils.calculatePP(
state.mapStarCount,
percent,
leaderboardType
);
if (currentPP === undefined) {
return;
}
state.setPp(currentPP);
},
noteFullyCut: (data: any) => {
const { noteCut } = data;
const state: any = useSongDataStore.getState();
const parts = noteCut.saberType.split("Saber");
const saberType = "saber" + parts[1];
let beforeCutScore = 0.0;
let afterCutScore = 0.0;
let cutDistanceScore = 0.0;
const cutDataSaber = cutData[saberType];
cutDataSaber.count[0]++;
cutDataSaber.count[1]++;
cutDataSaber.count[2]++;
cutDataSaber.totalScore[0] += noteCut.beforeCutScore;
cutDataSaber.totalScore[1] += noteCut.afterCutScore;
cutDataSaber.totalScore[2] += noteCut.cutDistanceScore;
beforeCutScore = cutDataSaber.totalScore[0] / cutDataSaber.count[0];
afterCutScore = cutDataSaber.totalScore[1] / cutDataSaber.count[1];
cutDistanceScore = cutDataSaber.totalScore[2] / cutDataSaber.count[2];
state.setSaberData(saberType, {
averagePreSwing: beforeCutScore,
averagePostSwing: afterCutScore,
cutDistanceScore: cutDistanceScore,
});
},
finished: () => {
const state = useSongDataStore.getState();
state.reset();
state.setInSong(false);
updatePlayerData();
},
menu: () => {
const state = useSongDataStore.getState();
state.reset();
state.setInSong(false);
updatePlayerData();
},
softFail: () => {
const state = useSongDataStore.getState();
state.setFailed(true);
},
pause: () => {
const state = useSongDataStore.getState();
state.setPaused(true);
},
resume: () => {
const state = useSongDataStore.getState();
state.setPaused(false);
},
noteCut: () => {},
noteMissed: () => {},
noteSpawned: () => {},
bombMissed: () => {},
beatmapEvent: () => {},
energyChanged: () => {},
obstacleEnter: () => {},
obstacleExit: () => {},
};

@ -37,7 +37,7 @@ export default class Home extends Component {
values: { values: {
socketAddr: undefined, socketAddr: undefined,
leaderboard: "ScoreSaber", leaderboardType: "ScoreSaber",
showPlayerStats: true, showPlayerStats: true,
showScoreInfo: false, showScoreInfo: false,
showSongInfo: false, showSongInfo: false,
@ -72,7 +72,10 @@ export default class Home extends Component {
const json = JSON.parse(localStorage.getItem("values")); const json = JSON.parse(localStorage.getItem("values"));
let values = {}; let values = {};
Object.entries(json.values).forEach((value) => { Object.entries(json.values).forEach((value) => {
if (value[0] !== undefined) { if (
value[0] !== undefined &&
this.state.values[value[0]] !== undefined
) {
values[value[0]] = value[1]; values[value[0]] = value[1];
} }
}); });
@ -96,10 +99,6 @@ export default class Home extends Component {
if (value[1] === undefined) { if (value[1] === undefined) {
return; return;
} }
if (value[0] == "leaderboard" && value[1] === "BeatLeader") {
values += `&beatLeader=true`;
return;
}
values += `&${value[0]}=${value[1]}`; values += `&${value[0]}=${value[1]}`;
}); });
@ -234,9 +233,11 @@ export default class Home extends Component {
<Spacer y={1} /> <Spacer y={1} />
<Text>Ranked leaderboard</Text> <Text>Ranked leaderboard</Text>
<Radio.Group <Radio.Group
defaultValue={this.state.values.leaderboard || "ScoreSaber"} defaultValue={
this.state.values.leaderboardType || "ScoreSaber"
}
onChange={(value) => { onChange={(value) => {
this.updateValue("leaderboard", value); this.updateValue("leaderboardType", value);
}} }}
> >
<Radio <Radio

@ -1,516 +1,78 @@
import { Link, Spinner } from "@nextui-org/react"; import axios from "axios";
import { NextSeo } from "next-seo"; import { useEffect } from "react";
import { Component } from "react"; import PlayerStats from "../components/PlayerStats";
import PlayerStats from "../../src/components/PlayerStats"; import ScoreStats from "../components/ScoreStats";
import ScoreStats from "../../src/components/ScoreStats"; import SongInfo from "../components/SongInfo";
import SongInfo from "../../src/components/SongInfo"; import { connectClient } from "../helpers/websocketClient";
import LeaderboardType from "../../src/consts/LeaderboardType"; import { useSettingsStore } from "../store/overlaySettingsStore";
import Utils from "../../src/utils/utils"; import { usePlayerDataStore } from "../store/playerDataStore";
import styles from "../styles/overlay.module.css"; import styles from "../styles/overlay.module.css";
export default class Overlay extends Component { export default function Overlay(props) {
#_beatSaverURL = ""; const query = JSON.parse(props.query);
const [setOverlaySettings, mounted, setMounted] = useSettingsStore(
constructor(props) { (state) => [state.setOverlaySettings, state.mounted, state.setMounted]
super(props);
this.cutData = [];
this.cutData.SaberA = {
count: [0, 0, 0],
totalScore: [0, 0, 0],
};
this.cutData.SaberB = {
count: [0, 0, 0],
totalScore: [0, 0, 0],
};
this._mounted = false;
this.state = {
hasError: false,
// Steam ID
id: undefined,
// Values from the query parameters
loadingPlayerData: true,
isConnectedToSocket: false,
isValidSteamId: false,
websiteType: "ScoreSaber",
showPlayerStats: true,
showScore: false,
showSongInfo: false,
shouldReplacePlayerInfoWithScore: false,
// Internal
loadedDuringSong: false,
socket: undefined,
data: undefined,
beatSaverData: undefined,
songInfo: undefined,
mapStarCount: undefined,
// UI elements
isPlayerInfoVisible: false,
isVisible: false,
// Score data
paused: true,
failed: false,
currentSongTime: 0,
currentScore: 0,
percentage: "100.00%",
SaberA: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
SaberB: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
};
this.setupTimer();
}
async componentDidMount() {
if (this._mounted === true) {
return;
}
this._mounted = true;
if (this.state.hasError) {
// Reload the page if there has been an error
console.log("There has been an error and the page was reloaded.");
return Router.reload(window.location.pathname);
}
console.log("Initializing...");
this.#_beatSaverURL =
document.location.origin + "/api/beatsaver/map?hash=%s";
const urlSearchParams = new URLSearchParams(window.location.search);
const params = Object.fromEntries(urlSearchParams.entries());
// Check what website the player wants to use
if (params.beatleader === "true" || params.beatLeader === "true") {
this.setState({ websiteType: "BeatLeader" });
}
const id = params.id;
if (!id) {
// Check if the id param is valid
this.setState({ isValidSteamId: false, loadingPlayerData: false });
return;
}
// Checks if the steam id is valid
const isValid = await this.validateSteamId(id);
if (!isValid) {
this.setState({ isValidSteamId: false, loadingPlayerData: false });
return;
}
this.setState({ id: id, isValidSteamId: true });
// Check if the player wants to disable their stats (pp, global pos, etc)
if (params.showPlayerStats === "false" || params.playerstats === "false") {
this.setState({ showPlayerStats: false });
}
if (this.state.showPlayerStats == true || params.playerstats == "true") {
setTimeout(async () => {
await this.updateData(id);
}, 10); // 10ms
this.setState({ isPlayerInfoVisible: true });
}
let shouldConnectSocket = false;
// Check if the player wants to show their current score information
if (params.showScoreInfo === "true" || params.scoreinfo === "true") {
this.setState({ showScore: true });
shouldConnectSocket = true;
}
if (params.shouldReplacePlayerInfoWithScore === "true") {
this.setState({ shouldReplacePlayerInfoWithScore: true });
}
// Check if the player wants to show the current song
if (params.showSongInfo === "true" || params.songinfo === "true") {
this.setState({ showSongInfo: true });
shouldConnectSocket = true;
}
if (shouldConnectSocket) {
if (this.state.isConnectedToSocket) return;
this.connectSocket(params.socketaddress);
}
}
// Handle Errors
static getDerivedStateFromError(error) {
return this.setState({ hasError: true });
}
componentDidCatch(error, errorInfo) {
console.log({ error, errorInfo });
}
// ---
// I'd love if HTTP Status just gave this data lmao
// HttpSiraStatus(https://github.com/denpadokei/HttpSiraStatus) does give this data.
isCurrentSongTimeProvided = false;
// we don't need to reset this to false because it is highly unlikely for a player to swap mods within a browser session
/**
* Setup the timer for the song time
*/
setupTimer() {
setInterval(() => {
if (this.isCurrentSongTimeProvided) {
return;
}
if (!this.state.paused && this.state.beatSaverData !== undefined) {
this.setState({ currentSongTime: this.state.currentSongTime + 1 });
}
}, 1000);
}
/**
* Update the current song time
*
* @param {[]} data The song data
*/
handleCurrentSongTime(data) {
try {
const time = data.status.performance.currentSongTime;
if (time !== undefined && time != null) {
this.isCurrentSongTimeProvided = true;
this.setState({ currentSongTime: time });
}
} catch (e) {
// do nothing
}
}
/**
* Fetch and update the data from the respective platform
*
* @param {string} id The steam id of the player
* @returns
*/
async updateData(id) {
const data = await fetch(
Utils.getWebsiteApi(this.state.websiteType).ApiUrl.PlayerData.replace(
"%s",
id
),
{
headers: {
"X-Requested-With": "BeatSaber Overlay",
},
}
); );
try { const updatePlayerData = usePlayerDataStore(
const json = await data.json(); (state) => state.updatePlayerData
this.setState({
loadingPlayerData: false,
id: id,
data: json,
});
} catch (e) {
// Catch error and use last known data
console.error(e);
}
}
/**
* Checks if the given steam id is valid or not
*
* @param {id} The Steam ID of the player to validate
*/
async validateSteamId(id) {
if (id.length !== 17) {
return false;
}
const data = await fetch(`/api/validateid?steamid=${id}`);
const json = await data.json();
return json.message === "Valid";
}
/**
* Setup the HTTP Status connection
*/
connectSocket(socketAddress) {
socketAddress =
(socketAddress === undefined
? "ws://localhost"
: `ws://${socketAddress}`) + ":6557/socket";
if (this.state.isConnectedToSocket) return;
if (this.state.isVisible) {
this.resetData(false);
}
console.log(`Connecting to ${socketAddress}`);
const socket = new WebSocket(socketAddress);
socket.addEventListener("open", () => {
console.log(`Connected to ${socketAddress}`);
this.setState({ isConnectedToSocket: true });
});
socket.addEventListener("close", () => {
console.log(
"Attempting to re-connect to the HTTP Status socket in 60 seconds."
); );
this.resetData(false);
this.setState({ isConnectedToSocket: false });
setTimeout(() => this.connectSocket(), 60_000);
});
socket.addEventListener("message", (message) => {
const json = JSON.parse(message.data);
this.handleCurrentSongTime(json);
if (!this.handlers[json.event]) {
console.log("Unhandled message from HTTP Status. (" + json.event + ")");
return;
}
this.handlers[json.event](json || []);
});
this.setState({ socket: socket });
}
/** useEffect(() => {
* Set the current songs beat saver url in {@link #_beatSaverURL} if (!mounted && props.isValidSteamId) {
* setMounted(true);
* @param {[]} songData
*/ async function setup() {
async setBeatSaver(songData) { await setOverlaySettings(query);
console.log("Updating BeatSaver info"); const showSongInfo = useSettingsStore.getState().showSongInfo;
const data = await fetch( const showScoreInfo = useSettingsStore.getState().showScoreInfo;
this.#_beatSaverURL.replace("%s", songData.levelId) if (showSongInfo || (showScoreInfo && typeof window !== "undefined")) {
await connectClient();
}
const showPlayerStats = useSettingsStore.getState().showPlayerStats;
if (showPlayerStats) {
await updatePlayerData();
}
}
setup();
}
}, [
query,
props.isValidSteamId,
setOverlaySettings,
mounted,
setMounted,
updatePlayerData,
]);
if (!props.isValidSteamId) {
return (
<div className={styles.invalidPlayer}>
<h1>Invalid Steam ID</h1>
<h3>Please check the id field in the url</h3>
</div>
); );
const json = await data.json();
this.setState({ beatSaverData: json });
const { characteristic, levelId, difficulty } = songData;
let mapHash = levelId.replace("custom_level_", "");
let mapStars = undefined;
if (this.state.websiteType === "BeatLeader") {
mapStars = await LeaderboardType.BeatLeader.getMapStarCount(
mapHash,
difficulty.replace("+", "Plus"),
characteristic
);
}
if (this.state.websiteType === "ScoreSaber") {
mapStars = await LeaderboardType.ScoreSaber.getMapStarCount(
mapHash,
difficulty.replace("+", "Plus"),
characteristic
);
}
this.setState({ mapStarCount: mapStars });
}
/**
* Cleanup the data and get ready for the next song
*
* @param {boolean} visible Whether to show info other than the player stats
*/
async resetData(visible, loadedDuringSong = false) {
if (this.state.showPlayerStats == true) {
setTimeout(async () => {
await this.updateData(this.state.id);
}, 1000); // 1 second
}
if (visible && this.state.shouldReplacePlayerInfoWithScore) {
this.setState({ isPlayerInfoVisible: false });
} else {
this.setState({ isPlayerInfoVisible: true });
}
this.cutData = [];
this.cutData.SaberA = {
count: [0, 0, 0],
totalScore: [0, 0, 0],
};
this.cutData.SaberB = {
count: [0, 0, 0],
totalScore: [0, 0, 0],
};
this.setState({
SaberA: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
SaberB: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
songInfo: undefined,
beatSaverData: undefined,
currentSongTime: 0,
currentScore: 0,
percentage: "100.00%",
isVisible: visible,
loadedDuringSong: loadedDuringSong,
mapStarCount: undefined,
});
}
// The HTTP Status handlers
handlers = {
hello: (data) => {
console.log("Hello from HTTP Status!");
if (
data.status &&
data.status.beatmap &&
this.state.songData === undefined
) {
console.log("Going into level during song, resetting data.");
this.resetData(true, true);
this.setState({ songData: data, paused: false });
if (this.state.showScore || this.state.showSongInfo) {
this.setBeatSaver(data.status.beatmap);
}
}
},
scoreChanged: (data) => {
const { status } = data;
const { score, relativeScore } = status.performance;
let finalScore = score;
if (finalScore == 0) {
finalScore = this.state.currentScore;
}
const percent = relativeScore * 100;
this.setState({
currentScore: finalScore,
percentage: percent.toFixed(2) + "%",
});
},
noteFullyCut: (data) => {
const { noteCut } = data;
let beforeCutScore = 0.0;
let afterCutScore = 0.0;
let cutDistanceScore = 0.0;
const cutDataSaber = this.cutData[noteCut.saberType];
cutDataSaber.count[0]++;
cutDataSaber.count[1]++;
cutDataSaber.count[2]++;
cutDataSaber.totalScore[0] += noteCut.beforeCutScore;
cutDataSaber.totalScore[1] += noteCut.afterCutScore;
cutDataSaber.totalScore[2] += noteCut.cutDistanceScore;
beforeCutScore = cutDataSaber.totalScore[0] / cutDataSaber.count[0];
afterCutScore = cutDataSaber.totalScore[1] / cutDataSaber.count[1];
cutDistanceScore = cutDataSaber.totalScore[2] / cutDataSaber.count[2];
const cutData = this.state[noteCut.saberType];
cutData.averagePreSwing = beforeCutScore;
cutData.averagePostSwing = afterCutScore;
cutData.cutDistanceScore = cutDistanceScore;
this.setState({ [noteCut.saberType]: cutData });
},
songStart: (data) => {
console.log("Going into level, resetting data.");
this.resetData(true);
this.setState({ songData: data, paused: false });
if (this.state.showScore || this.state.showSongInfo) {
this.setBeatSaver(data.status.beatmap);
}
},
finished: () => {
this.resetData(false);
},
softFail: () => {
this.setState({ failed: true });
},
pause: () => {
this.setState({ paused: true });
},
resume: () => {
this.setState({ paused: false });
},
menu: () => {
this.resetData(false);
},
noteCut: () => {},
noteMissed: () => {},
noteSpawned: () => {},
bombMissed: () => {},
beatmapEvent: () => {},
energyChanged: () => {},
obstacleEnter: () => {},
obstacleExit: () => {},
};
render() {
const {
isValidSteamId,
data,
websiteType,
showPlayerStats,
loadingPlayerData,
isPlayerInfoVisible,
shouldReplacePlayerInfoWithScore,
id,
} = this.state;
if (loadingPlayerData) {
return <Spinner size="xl" color="white"></Spinner>;
} }
return ( return (
<>
<NextSeo title="Overlay"></NextSeo>
<div className={styles.main}> <div className={styles.main}>
{!isValidSteamId ? ( <PlayerStats />
<div className={styles.invalidPlayer}> <ScoreStats />
<h1>Invalid player, please visit the main page.</h1> <SongInfo />
<Link href="/">
<a>Go Home</a>
</Link>
</div> </div>
) : (
<div className={styles.overlay}>
{showPlayerStats &&
!loadingPlayerData &&
isPlayerInfoVisible &&
!shouldReplacePlayerInfoWithScore ? (
<PlayerStats
pp={data.pp.toLocaleString("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
})}
globalPos={data.rank.toLocaleString()}
country={data.country}
countryRank={data.countryRank.toLocaleString()}
websiteType={websiteType}
avatar={`https://cdn.scoresaber.com/avatars/${id}.jpg`}
loadedDuringSong={this.state.loadedDuringSong}
/>
) : (
<></>
)}
{this.state.showScore && this.state.isVisible ? (
<ScoreStats data={this.state} />
) : (
<></>
)}
{this.state.showSongInfo &&
this.state.beatSaverData !== undefined &&
this.state.isVisible ? (
<SongInfo data={this.state} />
) : (
<></>
)}
</div>
)}
</div>
</>
); );
} }
export async function getServerSideProps(context) {
const steamId = context.query.id;
const steamIdResponse = await axios.get(
`${process.env.REACT_APP_SITE_URL}/api/validateid?steamid=${steamId}`
);
return {
props: {
isValidSteamId: steamIdResponse.data.message === "Valid",
query: JSON.stringify(context.query),
},
};
} }

517
src/pages/overlayold.js Normal file

@ -0,0 +1,517 @@
import { Link, Spinner } from "@nextui-org/react";
import { NextSeo } from "next-seo";
import { Component } from "react";
import PlayerStats from "../components/PlayerStats";
import ScoreStats from "../components/ScoreStats";
import SongInfo from "../components/SongInfo";
import LeaderboardType from "../consts/LeaderboardType";
import Utils from "../utils/utils";
import styles from "../styles/overlay.module.css";
export default class Overlay extends Component {
#_beatSaverURL = "";
constructor(props) {
super(props);
this.cutData = [];
this.cutData.SaberA = {
count: [0, 0, 0],
totalScore: [0, 0, 0],
};
this.cutData.SaberB = {
count: [0, 0, 0],
totalScore: [0, 0, 0],
};
this._mounted = false;
this.state = {
hasError: false,
// Steam ID
id: undefined,
// Values from the query parameters
loadingPlayerData: true,
isConnectedToSocket: false,
isValidSteamId: false,
websiteType: "ScoreSaber",
showPlayerStats: true,
showScore: false,
showSongInfo: false,
shouldReplacePlayerInfoWithScore: false,
// Internal
loadedDuringSong: false,
socket: undefined,
data: undefined,
beatSaverData: undefined,
songInfo: undefined,
mapStarCount: undefined,
// UI elements
isPlayerInfoVisible: false,
isVisible: false,
// Score data
paused: true,
failed: false,
currentSongTime: 0,
currentScore: 0,
percentage: "100.00%",
SaberA: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
SaberB: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
};
this.setupTimer();
}
async componentDidMount() {
if (this._mounted === true) {
return;
}
this._mounted = true;
if (this.state.hasError) {
// Reload the page if there has been an error
console.log("There has been an error and the page was reloaded.");
return Router.reload(window.location.pathname);
}
console.log("Initializing...");
this.#_beatSaverURL =
document.location.origin + "/api/beatsaver/map?hash=%s";
const urlSearchParams = new URLSearchParams(window.location.search);
const params = Object.fromEntries(urlSearchParams.entries());
// Check what website the player wants to use
if (params.beatleader === "true" || params.beatLeader === "true") {
this.setState({ websiteType: "BeatLeader" });
}
const id = params.id;
if (!id) {
// Check if the id param is valid
this.setState({ isValidSteamId: false, loadingPlayerData: false });
return;
}
// Checks if the steam id is valid
const isValid = await this.validateSteamId(id);
if (!isValid) {
this.setState({ isValidSteamId: false, loadingPlayerData: false });
return;
}
this.setState({ id: id, isValidSteamId: true });
// Check if the player wants to disable their stats (pp, global pos, etc)
if (params.showPlayerStats === "false" || params.playerstats === "false") {
this.setState({ showPlayerStats: false });
}
if (this.state.showPlayerStats == true || params.playerstats == "true") {
setTimeout(async () => {
await this.updateData(id);
}, 10); // 10ms
this.setState({ isPlayerInfoVisible: true });
}
let shouldConnectSocket = false;
// Check if the player wants to show their current score information
if (params.showScoreInfo === "true" || params.scoreinfo === "true") {
this.setState({ showScore: true });
shouldConnectSocket = true;
}
if (params.shouldReplacePlayerInfoWithScore === "true") {
this.setState({ shouldReplacePlayerInfoWithScore: true });
}
// Check if the player wants to show the current song
if (params.showSongInfo === "true" || params.songinfo === "true") {
this.setState({ showSongInfo: true });
shouldConnectSocket = true;
}
if (shouldConnectSocket) {
if (this.state.isConnectedToSocket) return;
this.connectSocket(params.socketaddress);
}
}
// Handle Errors
static getDerivedStateFromError(error) {
return this.setState({ hasError: true });
}
componentDidCatch(error, errorInfo) {
console.log({ error, errorInfo });
}
// ---
// I'd love if HTTP Status just gave this data lmao
// HttpSiraStatus(https://github.com/denpadokei/HttpSiraStatus) does give this data.
isCurrentSongTimeProvided = false;
// we don't need to reset this to false because it is highly unlikely for a player to swap mods within a browser session
/**
* Setup the timer for the song time
*/
setupTimer() {
setInterval(() => {
if (this.isCurrentSongTimeProvided) {
return;
}
if (!this.state.paused && this.state.beatSaverData !== undefined) {
this.setState({ currentSongTime: this.state.currentSongTime + 1 });
}
}, 1000);
}
/**
* Update the current song time
*
* @param {[]} data The song data
*/
handleCurrentSongTime(data) {
try {
const time = data.status.performance.currentSongTime;
if (time !== undefined && time != null) {
this.isCurrentSongTimeProvided = true;
this.setState({ currentSongTime: time });
}
} catch (e) {
// do nothing
}
}
/**
* Fetch and update the data from the respective platform
*
* @param {string} id The steam id of the player
* @returns
*/
async updateData(id) {
const data = await fetch(
Utils.getWebsiteApi(this.state.websiteType).ApiUrl.PlayerData.replace(
"%s",
id
),
{
headers: {
"X-Requested-With": "BeatSaber Overlay",
},
}
);
try {
const json = await data.json();
this.setState({
loadingPlayerData: false,
id: id,
data: json,
});
} catch (e) {
// Catch error and use last known data
console.error(e);
}
}
/**
* Checks if the given steam id is valid or not
*
* @param {id} The Steam ID of the player to validate
*/
async validateSteamId(id) {
if (id.length !== 17) {
return false;
}
const data = await fetch(`/api/validateid?steamid=${id}`);
const json = await data.json();
return json.message === "Valid";
}
/**
* Setup the HTTP Status connection
*/
connectSocket(socketAddress) {
socketAddress =
(socketAddress === undefined
? "ws://localhost"
: `ws://${socketAddress}`) + ":6557/socket";
if (this.state.isConnectedToSocket) return;
if (this.state.isVisible) {
this.resetData(false);
}
console.log(`Connecting to ${socketAddress}`);
const socket = new WebSocket(socketAddress);
socket.addEventListener("open", () => {
console.log(`Connected to ${socketAddress}`);
this.setState({ isConnectedToSocket: true });
});
socket.addEventListener("close", () => {
console.log(
"Attempting to re-connect to the HTTP Status socket in 60 seconds."
);
this.resetData(false);
this.setState({ isConnectedToSocket: false });
setTimeout(() => this.connectSocket(), 60_000);
});
socket.addEventListener("message", (message) => {
const json = JSON.parse(message.data);
this.handleCurrentSongTime(json);
if (!this.handlers[json.event]) {
console.log("Unhandled message from HTTP Status. (" + json.event + ")");
return;
}
this.handlers[json.event](json || []);
});
this.setState({ socket: socket });
}
/**
* Set the current songs beat saver url in {@link #_beatSaverURL}
*
* @param {[]} songData
*/
async setBeatSaver(songData) {
console.log("Updating BeatSaver info");
const data = await fetch(
this.#_beatSaverURL.replace("%s", songData.levelId)
);
const json = await data.json();
this.setState({ beatSaverData: json });
const { characteristic, levelId, difficulty } = songData;
let mapHash = levelId.replace("custom_level_", "");
let mapStars = undefined;
if (this.state.websiteType === "BeatLeader") {
mapStars = await LeaderboardType.BeatLeader.getMapStarCount(
mapHash,
difficulty.replace("+", "Plus"),
characteristic
);
}
if (this.state.websiteType === "ScoreSaber") {
mapStars = await LeaderboardType.ScoreSaber.getMapStarCount(
mapHash,
difficulty.replace("+", "Plus"),
characteristic
);
}
this.setState({ mapStarCount: mapStars });
}
/**
* Cleanup the data and get ready for the next song
*
* @param {boolean} visible Whether to show info other than the player stats
*/
async resetData(visible, loadedDuringSong = false) {
if (this.state.showPlayerStats == true) {
setTimeout(async () => {
await this.updateData(this.state.id);
}, 1000); // 1 second
}
if (visible && this.state.shouldReplacePlayerInfoWithScore) {
this.setState({ isPlayerInfoVisible: false });
} else {
this.setState({ isPlayerInfoVisible: true });
}
this.cutData = [];
this.cutData.SaberA = {
count: [0, 0, 0],
totalScore: [0, 0, 0],
};
this.cutData.SaberB = {
count: [0, 0, 0],
totalScore: [0, 0, 0],
};
this.setState({
SaberA: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
SaberB: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
songInfo: undefined,
beatSaverData: undefined,
currentSongTime: 0,
currentScore: 0,
percentage: "100.00%",
isVisible: visible,
loadedDuringSong: loadedDuringSong,
mapStarCount: undefined,
});
}
// The HTTP Status handlers
handlers = {
hello: (data) => {
console.log("Hello from HTTP Status!");
if (
data.status &&
data.status.beatmap &&
this.state.songData === undefined
) {
console.log("Going into level during song, resetting data.");
this.resetData(true, true);
this.setState({ songData: data, paused: false });
if (this.state.showScore || this.state.showSongInfo) {
this.setBeatSaver(data.status.beatmap);
}
}
},
scoreChanged: (data) => {
const { status } = data;
const { score, relativeScore } = status.performance;
let finalScore = score;
if (finalScore == 0) {
finalScore = this.state.currentScore;
}
const percent = relativeScore * 100;
this.setState({
currentScore: finalScore,
percentage: percent.toFixed(2) + "%",
});
},
noteFullyCut: (data) => {
const { noteCut } = data;
let beforeCutScore = 0.0;
let afterCutScore = 0.0;
let cutDistanceScore = 0.0;
const cutDataSaber = this.cutData[noteCut.saberType];
cutDataSaber.count[0]++;
cutDataSaber.count[1]++;
cutDataSaber.count[2]++;
cutDataSaber.totalScore[0] += noteCut.beforeCutScore;
cutDataSaber.totalScore[1] += noteCut.afterCutScore;
cutDataSaber.totalScore[2] += noteCut.cutDistanceScore;
beforeCutScore = cutDataSaber.totalScore[0] / cutDataSaber.count[0];
afterCutScore = cutDataSaber.totalScore[1] / cutDataSaber.count[1];
cutDistanceScore = cutDataSaber.totalScore[2] / cutDataSaber.count[2];
const cutData = this.state[noteCut.saberType];
cutData.averagePreSwing = beforeCutScore;
cutData.averagePostSwing = afterCutScore;
cutData.cutDistanceScore = cutDistanceScore;
this.setState({ [noteCut.saberType]: cutData });
},
songStart: (data) => {
console.log("Going into level, resetting data.");
this.resetData(true);
this.setState({ songData: data, paused: false });
if (this.state.showScore || this.state.showSongInfo) {
this.setBeatSaver(data.status.beatmap);
}
},
finished: () => {
this.resetData(false);
},
softFail: () => {
this.setState({ failed: true });
},
pause: () => {
this.setState({ paused: true });
},
resume: () => {
this.setState({ paused: false });
},
menu: () => {
this.resetData(false);
},
noteCut: () => {},
noteMissed: () => {},
noteSpawned: () => {},
bombMissed: () => {},
beatmapEvent: () => {},
energyChanged: () => {},
obstacleEnter: () => {},
obstacleExit: () => {},
};
render() {
const {
isValidSteamId,
data,
websiteType,
showPlayerStats,
loadingPlayerData,
isPlayerInfoVisible,
shouldReplacePlayerInfoWithScore,
id,
} = this.state;
if (loadingPlayerData) {
return <Spinner size="xl" color="white"></Spinner>;
}
return (
<>
<NextSeo title="Overlay"></NextSeo>
<div className={styles.main}>
{!isValidSteamId ? (
<div className={styles.invalidPlayer}>
<h1>Invalid player, please visit the main page.</h1>
<Link href="/">
<a>Go Home</a>
</Link>
</div>
) : (
<div className={styles.overlay}>
{showPlayerStats && !loadingPlayerData && isPlayerInfoVisible ? (
shouldReplacePlayerInfoWithScore ? (
<></>
) : (
<PlayerStats
pp={data.pp.toLocaleString("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
})}
globalPos={data.rank.toLocaleString()}
country={data.country}
countryRank={data.countryRank.toLocaleString()}
websiteType={websiteType}
avatar={`https://cdn.scoresaber.com/avatars/${id}.jpg`}
loadedDuringSong={this.state.loadedDuringSong}
/>
)
) : (
<></>
)}
{this.state.showScore && this.state.isVisible ? (
<ScoreStats data={this.state} />
) : (
<></>
)}
{this.state.showSongInfo &&
this.state.beatSaverData !== undefined &&
this.state.isVisible ? (
<SongInfo data={this.state} />
) : (
<></>
)}
</div>
)}
</div>
</>
);
}
}

@ -0,0 +1,18 @@
import create from "zustand";
interface DataState {
mounted: boolean;
loadedDuringSong: boolean;
setMounted: (isMounted: boolean) => void;
setLoadedDuringSong: (loadedDuringSong: boolean) => void;
}
export const useDataStore = create<DataState>()((set) => ({
mounted: false,
loadedDuringSong: false,
setMounted: (isMounted: boolean) => set(() => ({ mounted: isMounted })),
setLoadedDuringSong: (loadedDuringSong: boolean) =>
set(() => ({ loadedDuringSong: loadedDuringSong })),
}));

@ -0,0 +1,42 @@
import create from "zustand";
import Utils from "../utils/utils";
interface SettingsState {
mounted: boolean;
id: string;
socketAddr: string;
leaderboardType: string;
showPlayerStats: boolean;
showScoreInfo: boolean;
showSongInfo: boolean;
shouldReplacePlayerInfoWithScore: boolean;
setMounted: (isMounted: boolean) => void;
setOverlaySettings: (params: string) => void;
}
export const useSettingsStore = create<SettingsState>()((set) => ({
mounted: false,
id: "",
socketAddr: "localhost",
leaderboardType: "ScoreSaber",
showPlayerStats: true,
showScoreInfo: false,
showSongInfo: false,
shouldReplacePlayerInfoWithScore: false,
setMounted: (isMounted: boolean) => set(() => ({ mounted: isMounted })),
setOverlaySettings: (params: any) =>
set(() => {
let values: any = {};
Object.entries(params).forEach((value) => {
if (value[0] !== undefined) {
if (value[1] == "true" || value[1] == "false") {
values[value[0]] = Utils.stringToBoolean(value[1]);
} else {
values[value[0]] = value[1];
}
}
});
return values;
}),
}));

@ -0,0 +1,51 @@
import axios from "axios";
import create from "zustand";
import Utils from "../utils/utils";
import { useSettingsStore } from "./overlaySettingsStore";
interface PlayerDataState {
isLoading: boolean;
id: string;
pp: number;
avatar: string;
globalPos: number;
countryRank: number;
country: string;
updatePlayerData: () => void;
}
export const usePlayerDataStore = create<PlayerDataState>()((set) => ({
isLoading: true,
id: "",
pp: 0,
avatar: "",
globalPos: 0,
countryRank: 0,
country: "",
updatePlayerData: async () => {
const leaderboardType = useSettingsStore.getState().leaderboardType;
const playerId = useSettingsStore.getState().id;
const apiUrl = Utils.getWebsiteApi(
leaderboardType
).ApiUrl.PlayerData.replace("%s", playerId);
const response = await axios.get(apiUrl);
if (response.status !== 200) {
return;
}
const data = response.data;
console.log("Updated player data");
set(() => ({
id: playerId,
isLoading: false,
pp: data.pp,
avatar: data.avatar || data.profilePicture,
globalPos: data.rank,
countryRank: data.countryRank,
country: data.country,
}));
},
}));

180
src/store/songDataStore.ts Normal file

@ -0,0 +1,180 @@
import env from "@beam-australia/react-env";
import axios from "axios";
import create from "zustand";
import Utils from "../utils/utils";
import { useSettingsStore } from "./overlaySettingsStore";
interface SongDataState {
isLoading: boolean;
hasError: boolean;
inSong: boolean;
songTitle: string;
songSubTitle: string;
songLength: number;
songDifficulty: string;
mapStarCount: number;
mapArt: string;
bsr: string;
paused: boolean;
failed: boolean;
currentSongTime: number;
currentScore: number;
percentage: string;
currentPP: number | undefined;
saberA: {
cutDistanceScore: number;
averagePreSwing: number;
averagePostSwing: number;
};
saberB: {
cutDistanceScore: number;
averagePreSwing: number;
averagePostSwing: number;
};
reset: () => void;
updateMapData: (
mapHash: string,
mapDiff: string,
characteristic: string,
songTitle: string,
songSubTitle: string,
songLength: number
) => void;
setFailed: (failed: boolean) => void;
setPaused: (paused: boolean) => void;
setCurrentScore: (score: number) => void;
setPercent: (percent: string) => void;
setPp: (pp: number) => void;
setInSong: (isInSong: boolean) => void;
setSaberData: (saberType: string, cutData: any) => void;
}
export const useSongDataStore = create<SongDataState>()((set) => ({
isLoading: true,
hasError: false,
inSong: false,
songTitle: "",
songSubTitle: "",
songLength: 0,
songDifficulty: "",
mapStarCount: 0,
mapArt: "",
bsr: "",
paused: false,
failed: false,
currentSongTime: 0,
currentScore: 0,
percentage: "100%",
currentPP: undefined,
saberA: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
saberB: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
updateMapData: async (
mapHash: string,
mapDiff: string,
characteristic: string,
songTitle: string,
songSubTitle: string,
songLength: number
) => {
let hasError = false;
const leaderboardType = useSettingsStore.getState().leaderboardType;
const mapStars = await Utils.getWebsiteApi(leaderboardType).getMapStarCount(
mapHash,
mapDiff,
characteristic
);
const mapData = await axios.get(
`${env("SITE_URL")}/api/beatsaver/map?hash=${mapHash}`
);
if (mapData.status !== 200) {
return set({ isLoading: false, hasError: hasError });
}
const { bsr, mapArt } = mapData.data.data;
set({
isLoading: false,
hasError: hasError,
mapStarCount: mapStars,
bsr: bsr,
mapArt: mapArt,
songDifficulty: mapDiff,
songTitle: songTitle,
songSubTitle: songSubTitle,
songLength: songLength,
});
},
setFailed: (failed: boolean) => {
set({ failed: failed });
},
setPaused: (paused: boolean) => {
set({ paused: paused });
},
setCurrentScore: (score: number) => {
set({ currentScore: score });
},
setPercent: (percent: string) => {
set({ percentage: percent });
},
setSaberData: (saberType: string, saberData: any) => {
if (saberType === "saberA") {
set({ saberA: saberData });
} else if (saberType === "saberB") {
set({ saberB: saberData });
}
},
setPp: (pp: number) => {
set({ currentPP: pp });
},
setInSong: (isInSong: boolean) => {
set({ inSong: isInSong });
},
reset: () =>
set({
isLoading: true,
hasError: false,
songTitle: "",
songSubTitle: "",
songDifficulty: "",
mapStarCount: 0,
mapArt: "",
bsr: "",
paused: false,
failed: false,
currentSongTime: 0,
currentScore: 0,
percentage: "100%",
currentPP: undefined,
saberA: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
saberB: {
cutDistanceScore: 0.0,
averagePreSwing: 0.0,
averagePostSwing: 0.0,
},
}),
}));

@ -2,8 +2,5 @@
html, html,
body { body {
background-color: transparent; background-color: transparent;
margin: 0;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
} }

@ -2,6 +2,7 @@
display: flex; display: flex;
margin-left: 5px; margin-left: 5px;
margin-top: 5px; margin-top: 5px;
max-width: fit-content;
} }
.playerStatsContainer p { .playerStatsContainer p {

@ -1,8 +1,9 @@
.scoreStats { .scoreStats {
text-align: center; text-align: center;
position: absolute; position: fixed;
top: 0; top: 0;
right: 0; right: 0;
margin-top: 5px;
margin-right: 5px; margin-right: 5px;
min-width: 135px; min-width: 135px;

@ -1,12 +1,9 @@
.songInfoContainer { .songInfoContainer {
--pos: 0;
display: flex; display: flex;
position: fixed; position: fixed;
bottom: var(--pos);
left: var(--pos);
margin-left: 5px; margin-left: 5px;
margin-bottom: 5px; margin-bottom: 5px;
margin-top: 5px;
} }
.songInfoContainer img { .songInfoContainer img {
@ -22,7 +19,7 @@
font-weight: bold; font-weight: bold;
} }
.songInfoSongAuthor { .songInfoSongSubName {
font-size: 22px; font-size: 22px;
margin-top: -12px; margin-top: -12px;
margin-bottom: -3px; margin-bottom: -3px;

@ -6,7 +6,7 @@ export default class Utils {
/** /**
* Returns the information for the given website type. * Returns the information for the given website type.
* *
* @param {LeaderboardType} website * @param {string} website
* @returns The website type's information. * @returns The website type's information.
*/ */
static getWebsiteApi(website) { static getWebsiteApi(website) {
@ -43,4 +43,23 @@ export default class Utils {
static base64ToArrayBuffer(base64) { static base64ToArrayBuffer(base64) {
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
} }
static stringToBoolean = (stringValue) => {
switch (stringValue?.toLowerCase()?.trim()) {
case "true":
case "yes":
case "1":
return true;
case "false":
case "no":
case "0":
case null:
case undefined:
return false;
default:
return JSON.parse(stringValue);
}
};
} }

@ -15,6 +15,12 @@
"jsx": "preserve", "jsx": "preserve",
"incremental": true "incremental": true
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"src/helpers/web/getPlayerData.js",
"src/helpers/web/getPlayerData.js"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

4477
yarn.lock

File diff suppressed because it is too large Load Diff