add simple live score feed page
Some checks are pending
Deploy Website / deploy (push) Waiting to run
Deploy Backend / deploy (push) Successful in 4m15s

This commit is contained in:
Lee 2024-10-13 04:40:04 +01:00
parent ee212150fd
commit 4cc5893757
8 changed files with 224 additions and 24 deletions

@ -0,0 +1,11 @@
export type ScoreSaberWebsocketMessageToken = {
/**
* Command name
*/
commandName: "score";
/**
* Command data
*/
commandData: any;
};

@ -0,0 +1,20 @@
import { Metadata } from "next";
import ScoreFeed from "@/components/score/score-feed/score-feed";
import Card from "@/components/card";
export const metadata: Metadata = {
title: "Score Feed",
};
export default function ScoresPage() {
return (
<Card className="flex flex-col gap-2 w-full xl:w-[75%]">
<div>
<p className="font-semibold'">Live Score Feed</p>
<p className="text-gray-400">This is the real-time scores being set on ScoreSaber.</p>
</div>
<ScoreFeed />
</Card>
);
}

@ -41,7 +41,7 @@ export async function setCookieValue(name: CookieName, value: string) {
}); });
} }
const { set } = (await import("js-cookie")).default; const { set } = (await import("js-cookie")).default;
return set(name, value, { set(name, value, {
path: "/", path: "/",
}); });
} }

@ -4,7 +4,7 @@ import Link from "next/link";
import React from "react"; import React from "react";
import NavbarButton from "./navbar-button"; import NavbarButton from "./navbar-button";
import ProfileButton from "./profile-button"; import ProfileButton from "./profile-button";
import { TrendingUpIcon } from "lucide-react"; import { SwordIcon, TrendingUpIcon } from "lucide-react";
type NavbarItem = { type NavbarItem = {
name: string; name: string;
@ -26,6 +26,12 @@ const items: NavbarItem[] = [
align: "left", align: "left",
icon: <TrendingUpIcon className="h-5 w-5" />, icon: <TrendingUpIcon className="h-5 w-5" />,
}, },
{
name: "Score Feed",
link: "/scores",
align: "left",
icon: <SwordIcon className="h-5 w-5" />,
},
{ {
name: "Search", name: "Search",
link: "/search", link: "/search",

@ -0,0 +1,65 @@
"use client";
import { useScoreSaberWebsocket } from "@/hooks/use-scoresaber-websocket";
import { useEffect, useState } from "react";
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
import Score from "@/components/score/score";
import { parseDate } from "@ssr/common/utils/time-utils";
import Link from "next/link";
export default function ScoreFeed() {
const { connected, message } = useScoreSaberWebsocket();
const [scores, setScores] = useState<ScoreSaberPlayerScoreToken[]>([]);
useEffect(() => {
if (!message) {
return;
}
const { commandName, commandData } = message;
if (commandName !== "score") {
return;
}
setScores(prev => {
const newScores = [...prev, message.commandData];
if (newScores.length > 8) {
newScores.pop();
}
// Newest to oldest
return newScores.sort((a, b) => parseDate(b.score.timeSet).getTime() - parseDate(a.score.timeSet).getTime());
});
}, [message]);
if (!connected) {
return <p>Not Connected to the ScoreSaber Websocket :(</p>;
}
if (scores.length == 0) {
return <p>Waiting for scores...</p>;
}
return (
<div className="flex flex-col divide-y divide-border">
{scores.map(score => {
const player = score.score.leaderboardPlayerInfo;
return (
<div key={score.score.id} className="flex flex-col py-2">
<p className="text-sm">
Set by{" "}
<Link href={`/player/${player.id}`}>
<span className="text-pp hover:brightness-75 transition-all transform-gpu">{player.name}</span>
</Link>
</p>
<Score
playerScore={score}
settings={{
noScoreButtons: true,
}}
/>
</div>
);
})}
</div>
);
}

@ -18,20 +18,15 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
const mappersProfile = const mappersProfile =
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined; beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined;
const starCount = leaderboard.stars;
return ( return (
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<div className="relative flex justify-center h-[64px]"> <div className="relative flex justify-center h-[64px]">
<Tooltip <Tooltip
display={ display={
<> <>
<p> <p>Difficulty: {diff}</p>
Difficulty: <span className="font-bold">{diff}</span> {starCount > 0 && <p>Stars: {starCount.toFixed(2)}</p>}
</p>
{leaderboard.stars > 0 && (
<p>
Stars: <span className="font-bold">{leaderboard.stars}</span>
</p>
)}
</> </>
} }
> >
@ -41,9 +36,9 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
backgroundColor: songDifficultyToColor(diff) + "f0", // Transparency value (in hex 0-255) backgroundColor: songDifficultyToColor(diff) + "f0", // Transparency value (in hex 0-255)
}} }}
> >
{leaderboard.stars > 0 ? ( {starCount > 0 ? (
<div className="flex gap-1 items-center justify-center"> <div className="flex gap-1 items-center justify-center">
<p>{leaderboard.stars}</p> <p>{starCount.toFixed(2)}</p>
<StarIcon className="w-[14px] h-[14px]" /> <StarIcon className="w-[14px] h-[14px]" />
</div> </div>
) : ( ) : (

@ -24,15 +24,26 @@ type Props = {
* The score to display. * The score to display.
*/ */
playerScore: ScoreSaberPlayerScoreToken; playerScore: ScoreSaberPlayerScoreToken;
/**
* Score settings
*/
settings?: {
noScoreButtons: boolean;
};
}; };
export default function Score({ player, playerScore }: Props) { export default function Score({ player, playerScore, settings }: Props) {
const { score, leaderboard } = playerScore; const { score, leaderboard } = playerScore;
const [baseScore, setBaseScore] = useState<number>(score.baseScore); const [baseScore, setBaseScore] = useState<number>(score.baseScore);
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>(); const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false); const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
const fetchBeatSaverData = useCallback(async () => { const fetchBeatSaverData = useCallback(async () => {
// No need to fetch if no buttons
if (!settings?.noScoreButtons) {
return;
}
const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash); const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash);
setBeatSaverMap(beatSaverMapData); setBeatSaverMap(beatSaverMapData);
}, [leaderboard.songHash]); }, [leaderboard.songHash]);
@ -48,13 +59,20 @@ export default function Score({ player, playerScore }: Props) {
}, [fetchBeatSaverData]); }, [fetchBeatSaverData]);
const accuracy = (baseScore / leaderboard.maxScore) * 100; const accuracy = (baseScore / leaderboard.maxScore) * 100;
const pp = baseScore == score.baseScore ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy); const pp = baseScore === score.baseScore ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);
// Dynamic grid column classes
const gridColsClass = settings?.noScoreButtons
? "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_300px]" // Fewer columns if no buttons
: "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]"; // Original with buttons
return ( return (
<div className="pb-2 pt-2"> <div className="pb-2 pt-2">
{/* Score Info */} {/* Score Info */}
<div className="grid w-full gap-2 lg:gap-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]"> <div className={`grid w-full gap-2 lg:gap-0 ${gridColsClass}`}>
<ScoreRankInfo score={score} leaderboard={leaderboard} /> <ScoreRankInfo score={score} leaderboard={leaderboard} />
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} /> <ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
{settings?.noScoreButtons !== true && (
<ScoreButtons <ScoreButtons
leaderboard={leaderboard} leaderboard={leaderboard}
beatSaverMap={beatSaverMap} beatSaverMap={beatSaverMap}
@ -64,6 +82,7 @@ export default function Score({ player, playerScore }: Props) {
setBaseScore(score.baseScore); setBaseScore(score.baseScore);
}} }}
/> />
)}
<ScoreStats <ScoreStats
score={{ score={{
...score, ...score,

@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from "react";
import {
ScoreSaberWebsocketMessageToken,
} from "@ssr/common/types/token/scoresaber/websocket/scoresaber-websocket-message";
/**
* Connects to the ScoreSaber websocket.
* Waits until the page is loaded before establishing the connection.
*/
export const useScoreSaberWebsocket = () => {
const [connected, setConnected] = useState(false);
const [message, setMessage] = useState<ScoreSaberWebsocketMessageToken | null>(null); // Store the incoming message
const socketRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
// Only set isClient to true when we're on the client side
setIsClient(true);
}, []);
useEffect(() => {
// If not on the client side, don't attempt to connect
if (!isClient) {
return;
}
const connectWebSocket = () => {
// Create a new WebSocket instance
socketRef.current = new WebSocket("wss://scoresaber.com/ws");
socketRef.current.onopen = () => {
setConnected(true);
};
socketRef.current.onmessage = event => {
// Ignore welcome message
if (event.data === "Connected to the ScoreSaber WSS") {
return;
}
// Handle incoming messages here and store them in state
const messageData = JSON.parse(event.data);
setMessage(messageData); // Store the message in the state
};
socketRef.current.onclose = () => {
setConnected(false);
attemptReconnect();
};
socketRef.current.onerror = () => {
socketRef.current?.close(); // Close the socket on error
};
};
const attemptReconnect = () => {
// Clear any existing timeouts to avoid multiple reconnection attempts
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
// Try reconnecting after 5 seconds
reconnectTimeoutRef.current = setTimeout(() => {
connectWebSocket();
}, 5000);
};
// Initialize WebSocket connection
connectWebSocket();
// Cleanup function when component unmounts
return () => {
if (socketRef.current) {
socketRef.current.close();
socketRef.current = null;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, [isClient]); // Depend on isClient to ensure the code only runs on the client
return { connected, message }; // Return both the connection status and the last received message
};