diff --git a/projects/common/src/types/token/scoresaber/websocket/scoresaber-websocket-message.ts b/projects/common/src/types/token/scoresaber/websocket/scoresaber-websocket-message.ts new file mode 100644 index 0000000..23e4272 --- /dev/null +++ b/projects/common/src/types/token/scoresaber/websocket/scoresaber-websocket-message.ts @@ -0,0 +1,11 @@ +export type ScoreSaberWebsocketMessageToken = { + /** + * Command name + */ + commandName: "score"; + + /** + * Command data + */ + commandData: any; +}; diff --git a/projects/website/src/app/(pages)/scores/page.tsx b/projects/website/src/app/(pages)/scores/page.tsx new file mode 100644 index 0000000..a16d39d --- /dev/null +++ b/projects/website/src/app/(pages)/scores/page.tsx @@ -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 ( + +
+

Live Score Feed

+

This is the real-time scores being set on ScoreSaber.

+
+ + +
+ ); +} diff --git a/projects/website/src/common/cookie-utils.ts b/projects/website/src/common/cookie-utils.ts index cb769b2..b7a3d95 100644 --- a/projects/website/src/common/cookie-utils.ts +++ b/projects/website/src/common/cookie-utils.ts @@ -41,7 +41,7 @@ export async function setCookieValue(name: CookieName, value: string) { }); } const { set } = (await import("js-cookie")).default; - return set(name, value, { + set(name, value, { path: "/", }); } diff --git a/projects/website/src/components/navbar/navbar.tsx b/projects/website/src/components/navbar/navbar.tsx index 07b9160..c38a7de 100644 --- a/projects/website/src/components/navbar/navbar.tsx +++ b/projects/website/src/components/navbar/navbar.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import React from "react"; import NavbarButton from "./navbar-button"; import ProfileButton from "./profile-button"; -import { TrendingUpIcon } from "lucide-react"; +import { SwordIcon, TrendingUpIcon } from "lucide-react"; type NavbarItem = { name: string; @@ -26,6 +26,12 @@ const items: NavbarItem[] = [ align: "left", icon: , }, + { + name: "Score Feed", + link: "/scores", + align: "left", + icon: , + }, { name: "Search", link: "/search", diff --git a/projects/website/src/components/score/score-feed/score-feed.tsx b/projects/website/src/components/score/score-feed/score-feed.tsx new file mode 100644 index 0000000..7123862 --- /dev/null +++ b/projects/website/src/components/score/score-feed/score-feed.tsx @@ -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([]); + + 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

Not Connected to the ScoreSaber Websocket :(

; + } + + if (scores.length == 0) { + return

Waiting for scores...

; + } + + return ( +
+ {scores.map(score => { + const player = score.score.leaderboardPlayerInfo; + return ( +
+

+ Set by{" "} + + {player.name} + +

+ +
+ ); + })} +
+ ); +} diff --git a/projects/website/src/components/score/score-info.tsx b/projects/website/src/components/score/score-info.tsx index 462e452..6e1a21a 100644 --- a/projects/website/src/components/score/score-info.tsx +++ b/projects/website/src/components/score/score-info.tsx @@ -18,20 +18,15 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) { const mappersProfile = beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined; + const starCount = leaderboard.stars; return (
-

- Difficulty: {diff} -

- {leaderboard.stars > 0 && ( -

- Stars: {leaderboard.stars} -

- )} +

Difficulty: {diff}

+ {starCount > 0 &&

Stars: {starCount.toFixed(2)}

} } > @@ -41,9 +36,9 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) { backgroundColor: songDifficultyToColor(diff) + "f0", // Transparency value (in hex 0-255) }} > - {leaderboard.stars > 0 ? ( + {starCount > 0 ? (
-

{leaderboard.stars}

+

{starCount.toFixed(2)}

) : ( diff --git a/projects/website/src/components/score/score.tsx b/projects/website/src/components/score/score.tsx index 8b53f9a..0b5161a 100644 --- a/projects/website/src/components/score/score.tsx +++ b/projects/website/src/components/score/score.tsx @@ -24,15 +24,26 @@ type Props = { * The score to display. */ 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 [baseScore, setBaseScore] = useState(score.baseScore); const [beatSaverMap, setBeatSaverMap] = useState(); const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false); const fetchBeatSaverData = useCallback(async () => { + // No need to fetch if no buttons + if (!settings?.noScoreButtons) { + return; + } const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash); setBeatSaverMap(beatSaverMapData); }, [leaderboard.songHash]); @@ -48,22 +59,30 @@ export default function Score({ player, playerScore }: Props) { }, [fetchBeatSaverData]); 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 (
{/* Score Info */} -
+
- { - setBaseScore(score.baseScore); - }} - /> + {settings?.noScoreButtons !== true && ( + { + setBaseScore(score.baseScore); + }} + /> + )} { + const [connected, setConnected] = useState(false); + const [message, setMessage] = useState(null); // Store the incoming message + const socketRef = useRef(null); + const reconnectTimeoutRef = useRef(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 +};