This commit is contained in:
@ -3,7 +3,6 @@ import { ssrSettings } from "@/ssrSettings";
|
||||
import clsx from "clsx";
|
||||
import { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import Image from "next/image";
|
||||
import Script from "next/script";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import "./globals.css";
|
||||
@ -49,16 +48,7 @@ export default function RootLayout({
|
||||
src="https://analytics.fascinated.cc/js/script.js"
|
||||
/>
|
||||
|
||||
<body className={clsx(font.className, "bg-black text-primary")}>
|
||||
<div className="fixed left-0 top-0 z-0 h-full w-full blur-sm">
|
||||
<Image
|
||||
className="object-fill object-center"
|
||||
alt="Background image"
|
||||
src={"/assets/background.webp"}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
|
||||
<body className={clsx(font.className, "text-primary")}>
|
||||
<AppProvider>{children}</AppProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
115
src/app/overlay/builder/page.tsx
Normal file
115
src/app/overlay/builder/page.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/Container";
|
||||
import { Input } from "@/components/input/Input";
|
||||
import { RadioInput } from "@/components/input/RadioInput";
|
||||
import { SwitchInput } from "@/components/input/SwitchInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useOverlaySettingsStore } from "@/store/overlaySettingsStore";
|
||||
import useStore from "@/utils/useStore";
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Opens the overlay with the current settings
|
||||
*
|
||||
* @param settings the settings to pass to the overlay
|
||||
*/
|
||||
function goToOverlay(settings: any) {
|
||||
window.open(`/overlay?data=${JSON.stringify(settings)}`, "_blank");
|
||||
}
|
||||
|
||||
export default function Analytics() {
|
||||
const settingsStore = useStore(useOverlaySettingsStore, (store) => store);
|
||||
if (!settingsStore) return null;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Container>
|
||||
<Card className="mt-2">
|
||||
<CardTitle className="p-3">
|
||||
<h1>Overlay Builder</h1>
|
||||
</CardTitle>
|
||||
|
||||
<CardContent>
|
||||
<p className="mb-2">
|
||||
Confused on how to use this? Check out the{" "}
|
||||
<span className="transform-gpu text-pp-blue transition-all hover:opacity-80">
|
||||
<Link href={"https://www.youtube.com/watch?v=IjctLf1nX8w"}>
|
||||
tutorial
|
||||
</Link>
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
|
||||
<Input
|
||||
id="ip-address"
|
||||
label="IP Address"
|
||||
defaultValue={settingsStore.ipAddress}
|
||||
onChange={(e) => {
|
||||
settingsStore.setIpAddress(e);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
id="account-id"
|
||||
label="Account ID"
|
||||
defaultValue={settingsStore.accountId}
|
||||
onChange={(e) => {
|
||||
settingsStore.setAccountId(e);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RadioInput
|
||||
id="platform"
|
||||
label="Platform"
|
||||
defaultValue={settingsStore.platform}
|
||||
items={[
|
||||
{
|
||||
id: "scoresaber",
|
||||
value: "ScoreSaber",
|
||||
},
|
||||
// {
|
||||
// id: "beatleader",
|
||||
// value: "BeatLeader",
|
||||
// },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
settingsStore.setPlatform(value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-2">
|
||||
<Label>Settings</Label>
|
||||
<SwitchInput
|
||||
id="show-player-stats"
|
||||
label="Show Player Stats"
|
||||
defaultChecked={settingsStore.settings.showPlayerStats}
|
||||
onChange={(value) => {
|
||||
settingsStore.setShowPlayerStats(value);
|
||||
}}
|
||||
/>
|
||||
<SwitchInput
|
||||
id="show-song-info"
|
||||
label="Show Song Info"
|
||||
defaultChecked={settingsStore.settings.showSongInfo}
|
||||
onChange={(value) => {
|
||||
settingsStore.setShowSongInfo(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mt-3"
|
||||
onClick={() => {
|
||||
goToOverlay(settingsStore);
|
||||
}}
|
||||
>
|
||||
Open Overlay
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
119
src/app/overlay/page.tsx
Normal file
119
src/app/overlay/page.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/Container";
|
||||
import Spinner from "@/components/Spinner";
|
||||
import PlayerStats from "@/components/overlay/PlayerStats";
|
||||
import ScoreStats from "@/components/overlay/ScoreStats";
|
||||
import SongInfo from "@/components/overlay/SongInfo";
|
||||
import { Card, CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import { HttpSiraStatus } from "@/overlay/httpSiraStatus";
|
||||
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
||||
import { Component } from "react";
|
||||
|
||||
const UPDATE_INTERVAL = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
interface OverlayProps {}
|
||||
|
||||
interface OverlayState {
|
||||
mounted: boolean;
|
||||
player: ScoresaberPlayer | undefined;
|
||||
settings: any | undefined;
|
||||
}
|
||||
|
||||
export default class Overlay extends Component<OverlayProps, OverlayState> {
|
||||
constructor(props: OverlayProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
mounted: false,
|
||||
player: undefined,
|
||||
settings: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
updatePlayer = async (playerId: string) => {
|
||||
console.log(`Updating player stats for ${playerId}`);
|
||||
const player = await ScoreSaberAPI.fetchPlayerData(playerId);
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
this.setState({ player });
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (this.state.mounted) {
|
||||
return;
|
||||
}
|
||||
this.setState({ mounted: true });
|
||||
if (!this.state.mounted) {
|
||||
HttpSiraStatus.connectWebSocket();
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const searchParams = url.searchParams;
|
||||
const data = searchParams.get("data");
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const settings = JSON.parse(data);
|
||||
this.setState({ settings: settings });
|
||||
|
||||
this.updatePlayer(settings.accountId);
|
||||
setInterval(() => {
|
||||
this.updatePlayer(settings.accountId);
|
||||
}, UPDATE_INTERVAL);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { player } = this.state;
|
||||
|
||||
if (!this.state.mounted || !player) {
|
||||
return (
|
||||
<main className="flex items-center p-3">
|
||||
<Spinner />
|
||||
<p className="text-xl">Loading player data</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.state.settings) {
|
||||
return (
|
||||
<main>
|
||||
<Container>
|
||||
<Card className="mt-2 p-3">
|
||||
<CardTitle>Overlay</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
<p>
|
||||
This page is meant to be used as an overlay for streaming.
|
||||
</p>
|
||||
<p>
|
||||
To generate an overlay, go to the builder{" "}
|
||||
<a
|
||||
className="transform-gpu text-pp-blue transition-all hover:opacity-80"
|
||||
href="/overlay/builder"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</CardDescription>
|
||||
</Card>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div>
|
||||
<PlayerStats player={player} />
|
||||
<ScoreStats />
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0">
|
||||
<SongInfo />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@
|
||||
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
|
||||
import { useSettingsStore } from "@/store/settingsStore";
|
||||
import React from "react";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { TooltipProvider } from "./ui/Tooltip";
|
||||
import { ThemeProvider } from "./ui/theme-provider";
|
||||
|
||||
@ -52,7 +53,15 @@ export default class AppProvider extends React.Component {
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
<TooltipProvider>{props.children}</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<ToastContainer
|
||||
className="z-50"
|
||||
position="top-right"
|
||||
theme="dark"
|
||||
pauseOnFocusLoss={false}
|
||||
/>
|
||||
{props.children}
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
@ -1,17 +1,19 @@
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import Image from "next/image";
|
||||
import Footer from "./Footer";
|
||||
import Navbar from "./Navbar";
|
||||
|
||||
export default function Container({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<ToastContainer
|
||||
className="z-50"
|
||||
position="top-right"
|
||||
theme="dark"
|
||||
pauseOnFocusLoss={false}
|
||||
/>
|
||||
<div className="m-auto flex h-screen min-h-full flex-col items-center opacity-90 md:max-w-[1200px]">
|
||||
<div className="fixed left-0 top-0 z-0 h-full w-full blur-sm">
|
||||
<Image
|
||||
className="object-fill object-center"
|
||||
alt="Background image"
|
||||
src={"/assets/background.webp"}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
<div className="z-[9999] m-auto flex h-screen min-h-full flex-col items-center opacity-90 md:max-w-[1200px]">
|
||||
<Navbar />
|
||||
<div className="w-full flex-1">{children}</div>
|
||||
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
ServerIcon,
|
||||
UserIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { GlobeAltIcon } from "@heroicons/react/24/outline";
|
||||
import { GlobeAltIcon, TvIcon } from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
import Avatar from "./Avatar";
|
||||
import { Button } from "./ui/button";
|
||||
@ -129,6 +129,12 @@ export default function Navbar() {
|
||||
icon={<GlobeAltIcon height={23} width={23} />}
|
||||
href="/ranking/global/1"
|
||||
/>
|
||||
<NavbarButton
|
||||
ariaLabel="View the overlay builder"
|
||||
text="Overlay"
|
||||
icon={<TvIcon height={23} width={23} />}
|
||||
href="/overlay/builder"
|
||||
/>
|
||||
<NavbarButton
|
||||
ariaLabel="View analytics for Scoresaber"
|
||||
text="Analytics"
|
||||
|
24
src/components/input/Input.tsx
Normal file
24
src/components/input/Input.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Input as Inputtt } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
|
||||
type InputProps = {
|
||||
label: string;
|
||||
id: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export function Input({ label, id, defaultValue, onChange }: InputProps) {
|
||||
return (
|
||||
<>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Inputtt
|
||||
id={id}
|
||||
defaultValue={defaultValue}
|
||||
onChange={(e) => {
|
||||
onChange && onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
45
src/components/input/RadioInput.tsx
Normal file
45
src/components/input/RadioInput.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Label } from "../ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
||||
|
||||
type RadioProps = {
|
||||
id: string;
|
||||
defaultValue: string;
|
||||
label?: string;
|
||||
items: {
|
||||
id: string;
|
||||
value: string;
|
||||
label?: string;
|
||||
}[];
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export function RadioInput({
|
||||
id,
|
||||
defaultValue,
|
||||
label,
|
||||
items,
|
||||
onChange,
|
||||
}: RadioProps) {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
{id && label && <Label htmlFor={id}>{label}</Label>}
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={defaultValue}
|
||||
className="mt-2"
|
||||
onValueChange={(value) => onChange && onChange(value)}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={item.id} id={item.id}>
|
||||
{item.value}
|
||||
</RadioGroupItem>
|
||||
<Label htmlFor={item.id}>{item.value}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
27
src/components/input/SwitchInput.tsx
Normal file
27
src/components/input/SwitchInput.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Label } from "../ui/label";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
type SwitchProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
defaultChecked?: boolean;
|
||||
onChange?: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export function SwitchInput({
|
||||
id,
|
||||
label,
|
||||
defaultChecked,
|
||||
onChange,
|
||||
}: SwitchProps) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<Switch
|
||||
id={id}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={(value) => onChange && onChange(value)}
|
||||
/>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/components/overlay/PlayerStats.tsx
Normal file
34
src/components/overlay/PlayerStats.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||
import { formatNumber } from "@/utils/numberUtils";
|
||||
import { GlobeAltIcon } from "@heroicons/react/20/solid";
|
||||
import Image from "next/image";
|
||||
import CountyFlag from "../CountryFlag";
|
||||
|
||||
type PlayerStatsProps = {
|
||||
player: ScoresaberPlayer;
|
||||
};
|
||||
|
||||
export default function PlayerStats({ player }: PlayerStatsProps) {
|
||||
return (
|
||||
<div className="flex gap-2 p-2">
|
||||
<Image
|
||||
alt="Player profile picture"
|
||||
className="rounded-md"
|
||||
src={player.profilePicture}
|
||||
width={180}
|
||||
height={180}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-3xl font-bold">{formatNumber(player.pp, 2)}pp</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeAltIcon width={25} height={25} />
|
||||
<p className="text-3xl">#{formatNumber(player.rank)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CountyFlag className="w-[25px]" countryCode={player.country} />
|
||||
<p className="text-3xl">#{formatNumber(player.countryRank)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
20
src/components/overlay/ScoreStats.tsx
Normal file
20
src/components/overlay/ScoreStats.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useOverlayDataStore } from "@/store/overlayDataStore";
|
||||
import { formatNumber } from "@/utils/numberUtils";
|
||||
import useStore from "@/utils/useStore";
|
||||
|
||||
export default function ScoreStats() {
|
||||
const dataStore = useStore(useOverlayDataStore, (store) => store);
|
||||
if (!dataStore) return null;
|
||||
const { scoreStats } = dataStore;
|
||||
if (!scoreStats) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-2">
|
||||
<p className="text-2xl">{formatNumber(scoreStats.score)}</p>
|
||||
<p className="text-2xl">Combo: {formatNumber(scoreStats.combo)}</p>
|
||||
<p className="text-2xl">
|
||||
{scoreStats.rank} {scoreStats.accuracy.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
46
src/components/overlay/SongInfo.tsx
Normal file
46
src/components/overlay/SongInfo.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useOverlayDataStore } from "@/store/overlayDataStore";
|
||||
import { songDifficultyToColor } from "@/utils/songUtils";
|
||||
import useStore from "@/utils/useStore";
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function SongInfo() {
|
||||
const dataStore = useStore(useOverlayDataStore, (store) => store);
|
||||
if (!dataStore) return null;
|
||||
const { paused, songInfo } = dataStore;
|
||||
if (!songInfo) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex transform-gpu gap-2 p-2 transition-all",
|
||||
paused ? "grayscale" : "grayscale-0",
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
className="rounded-md"
|
||||
alt="Song Image"
|
||||
src={songInfo.art}
|
||||
width={120}
|
||||
height={120}
|
||||
/>
|
||||
<div className="flex flex-col justify-between pb-2 pt-1">
|
||||
<div>
|
||||
<p className="text-xl font-bold">{songInfo.songName}</p>
|
||||
<p className="text-md">{songInfo.songMapper}</p>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<p
|
||||
className="text-md rounded-md p-[3px]"
|
||||
style={{
|
||||
backgroundColor: songDifficultyToColor(songInfo.difficulty),
|
||||
}}
|
||||
>
|
||||
{songInfo.difficulty}
|
||||
</p>
|
||||
<p className="text-md">!bsr {songInfo.bsr}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/utils/utils"
|
||||
import { cn } from "@/utils/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@ -10,12 +10,12 @@ const Card = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
@ -37,12 +37,12 @@ const CardTitle = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
@ -53,16 +53,16 @@ const CardDescription = React.forwardRef<
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@ -73,7 +73,14 @@ const CardFooter = React.forwardRef<
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
|
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/utils/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/utils/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
44
src/components/ui/radio-group.tsx
Normal file
44
src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/utils/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/utils/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
127
src/overlay/httpSiraStatus.ts
Normal file
127
src/overlay/httpSiraStatus.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { useOverlayDataStore } from "@/store/overlayDataStore";
|
||||
import { useOverlaySettingsStore } from "@/store/overlaySettingsStore";
|
||||
import { BeatsaverAPI } from "@/utils/beatsaver/api";
|
||||
import { w3cwebsocket as WebSocketClient } from "websocket";
|
||||
|
||||
const settingsStore = useOverlaySettingsStore;
|
||||
const overlayDataStore = useOverlayDataStore;
|
||||
let isConnected = false;
|
||||
|
||||
async function loadIntoSong(data: any) {
|
||||
const { beatmap, performance } = data;
|
||||
const hash = beatmap.songHash;
|
||||
const beatsaverMapData = await BeatsaverAPI.fetchMapByHash(hash);
|
||||
if (beatsaverMapData == undefined) return;
|
||||
const coverURL =
|
||||
beatsaverMapData.versions[beatsaverMapData.versions.length - 1].coverURL;
|
||||
|
||||
overlayDataStore.setState({
|
||||
scoreStats: {
|
||||
accuracy: performance.relativeScore * 100,
|
||||
score: performance.rawScore,
|
||||
combo: performance.combo,
|
||||
rank: performance.rank,
|
||||
},
|
||||
songInfo: {
|
||||
art: coverURL,
|
||||
bsr: beatsaverMapData.id,
|
||||
difficulty: beatmap.difficulty,
|
||||
songMapper: beatsaverMapData.metadata.levelAuthorName,
|
||||
songName: beatsaverMapData.metadata.songName,
|
||||
songSubName: beatsaverMapData.metadata.songSubName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type Handlers = {
|
||||
[key: string]: (data: any) => void;
|
||||
};
|
||||
const handlers: Handlers = {
|
||||
hello: async (data: any) => {
|
||||
if (!data.beatmap || !data.performance) {
|
||||
return;
|
||||
}
|
||||
loadIntoSong(data);
|
||||
},
|
||||
|
||||
songStart: (data: any) => {
|
||||
loadIntoSong(data);
|
||||
},
|
||||
|
||||
scoreChanged: (data: any) => {
|
||||
const { performance } = data;
|
||||
if (performance == undefined) return;
|
||||
|
||||
overlayDataStore.setState({
|
||||
scoreStats: {
|
||||
accuracy: performance.relativeScore * 100,
|
||||
score: performance.rawScore,
|
||||
combo: performance.combo,
|
||||
rank: performance.rank,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Left the song
|
||||
finished: (data: any) => {
|
||||
overlayDataStore.setState({
|
||||
scoreStats: undefined,
|
||||
songInfo: undefined,
|
||||
});
|
||||
},
|
||||
menu: (data: any) => {
|
||||
overlayDataStore.setState({
|
||||
scoreStats: undefined,
|
||||
songInfo: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
// pause & resume
|
||||
pause: (data: any) => {
|
||||
overlayDataStore.setState({
|
||||
paused: true,
|
||||
});
|
||||
},
|
||||
resume: (data: any) => {
|
||||
overlayDataStore.setState({
|
||||
paused: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function connectWebSocket() {
|
||||
if (isConnected) return;
|
||||
const client = new WebSocketClient(
|
||||
`ws://${settingsStore.getState().ipAddress}:6557/socket`,
|
||||
);
|
||||
isConnected = true;
|
||||
client.onopen = () => {
|
||||
console.log("WebSocket Connected to HttpSiraStatus");
|
||||
};
|
||||
client.onerror = (error) => {
|
||||
console.error(error);
|
||||
};
|
||||
client.onclose = () => {
|
||||
isConnected = false;
|
||||
console.log(
|
||||
"Lost connection to HttpSiraStatus, reconnecting in 5 seconds...",
|
||||
);
|
||||
setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 5000); // 5 seconds
|
||||
};
|
||||
client.onmessage = (message) => {
|
||||
const data = JSON.parse(message.data.toString());
|
||||
const handler = handlers[data.event];
|
||||
//console.log(data.event);
|
||||
if (handler == undefined) {
|
||||
console.error(`No handler for ${data.event}`);
|
||||
return;
|
||||
}
|
||||
handler(data.status);
|
||||
};
|
||||
}
|
||||
|
||||
export const HttpSiraStatus = {
|
||||
connectWebSocket,
|
||||
};
|
21
src/schemas/beatsaver/BeatsaverMap.ts
Normal file
21
src/schemas/beatsaver/BeatsaverMap.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { BeatsaverMapMetadata } from "./BeatsaverMapMetadata";
|
||||
import { BeatsaverMapStats } from "./BeatsaverMapStats";
|
||||
import { BeatsaverMapVersion } from "./BeatsaverMapVersion";
|
||||
import { BeatsaverUploader } from "./BeatsaverUploader";
|
||||
|
||||
export type BeatsaverMap = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
uploader: BeatsaverUploader;
|
||||
metadata: BeatsaverMapMetadata;
|
||||
stats: BeatsaverMapStats;
|
||||
uploaded: string;
|
||||
automapper: boolean;
|
||||
ranked: boolean;
|
||||
qualified: boolean;
|
||||
versions: BeatsaverMapVersion[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastPublishedAt: string;
|
||||
};
|
22
src/schemas/beatsaver/BeatsaverMapDifficulty.ts
Normal file
22
src/schemas/beatsaver/BeatsaverMapDifficulty.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { BeatsaverMapSummary } from "./BeatsaverMapSummary";
|
||||
|
||||
export type BeatsaverMapDifficulty = {
|
||||
njs: number;
|
||||
offset: number;
|
||||
notes: number;
|
||||
bombs: number;
|
||||
obstacles: number;
|
||||
nps: number;
|
||||
length: number;
|
||||
characteristic: string;
|
||||
difficulty: string;
|
||||
events: number;
|
||||
chroma: boolean;
|
||||
me: boolean;
|
||||
ne: boolean;
|
||||
cinema: boolean;
|
||||
seconds: number;
|
||||
paritySummary: BeatsaverMapSummary;
|
||||
maxScore: number;
|
||||
label: string;
|
||||
};
|
8
src/schemas/beatsaver/BeatsaverMapMetadata.ts
Normal file
8
src/schemas/beatsaver/BeatsaverMapMetadata.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export type BeatsaverMapMetadata = {
|
||||
bpm: number;
|
||||
duration: number;
|
||||
songName: string;
|
||||
songSubName: string;
|
||||
songAuthorName: string;
|
||||
levelAuthorName: string;
|
||||
};
|
7
src/schemas/beatsaver/BeatsaverMapStats.ts
Normal file
7
src/schemas/beatsaver/BeatsaverMapStats.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type BeatsaverMapStats = {
|
||||
plays: number;
|
||||
downloads: number;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
score: number;
|
||||
};
|
5
src/schemas/beatsaver/BeatsaverMapSummary.ts
Normal file
5
src/schemas/beatsaver/BeatsaverMapSummary.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type BeatsaverMapSummary = {
|
||||
errors: number;
|
||||
warns: number;
|
||||
resets: number;
|
||||
};
|
12
src/schemas/beatsaver/BeatsaverMapVersion.ts
Normal file
12
src/schemas/beatsaver/BeatsaverMapVersion.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { BeatsaverMapDifficulty } from "./BeatsaverMapDifficulty";
|
||||
|
||||
export type BeatsaverMapVersion = {
|
||||
hash: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
sageScore: number;
|
||||
diffs: BeatsaverMapDifficulty[];
|
||||
downloadURL: string;
|
||||
coverURL: string;
|
||||
previewURL: string;
|
||||
};
|
10
src/schemas/beatsaver/BeatsaverUploader.ts
Normal file
10
src/schemas/beatsaver/BeatsaverUploader.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type BeatsaverUploader = {
|
||||
id: string;
|
||||
name: string;
|
||||
hash: string;
|
||||
avatar: string;
|
||||
type: string;
|
||||
admin: boolean;
|
||||
curator: boolean;
|
||||
playlistUrl: string;
|
||||
};
|
42
src/store/overlayDataStore.ts
Normal file
42
src/store/overlayDataStore.ts
Normal file
@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
interface OverlayDataStore {
|
||||
paused: boolean;
|
||||
scoreStats:
|
||||
| {
|
||||
accuracy: number;
|
||||
score: number;
|
||||
rank: string;
|
||||
combo: number;
|
||||
}
|
||||
| undefined;
|
||||
songInfo:
|
||||
| {
|
||||
art: string;
|
||||
songName: string;
|
||||
songSubName: string;
|
||||
songMapper: string;
|
||||
difficulty: string;
|
||||
bsr: string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export const useOverlayDataStore = create<OverlayDataStore>()((set, get) => ({
|
||||
paused: false,
|
||||
songInfo: undefined,
|
||||
scoreStats: undefined,
|
||||
|
||||
setScoreStats(scoreStats: OverlayDataStore["scoreStats"]) {
|
||||
set({
|
||||
scoreStats,
|
||||
});
|
||||
},
|
||||
setSongInfo(songInfo: OverlayDataStore["songInfo"]) {
|
||||
set({
|
||||
songInfo,
|
||||
});
|
||||
},
|
||||
}));
|
71
src/store/overlaySettingsStore.ts
Normal file
71
src/store/overlaySettingsStore.ts
Normal file
@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { IDBStorage } from "./IndexedDBStorage";
|
||||
|
||||
interface OverlaySettingsStore {
|
||||
ipAddress: string;
|
||||
accountId: string;
|
||||
platform: string;
|
||||
settings: {
|
||||
showPlayerStats: boolean;
|
||||
showSongInfo: boolean;
|
||||
};
|
||||
|
||||
setIpAddress: (ipAddress: string) => void;
|
||||
setAccountId: (accountId: string) => void;
|
||||
setPlatform: (platform: string) => void;
|
||||
setShowPlayerStats: (showPlayerStats: boolean) => void;
|
||||
setShowSongInfo: (showSongInfo: boolean) => void;
|
||||
}
|
||||
|
||||
export const useOverlaySettingsStore = create<OverlaySettingsStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
ipAddress: "localhost",
|
||||
accountId: "",
|
||||
platform: "scoresaber",
|
||||
settings: {
|
||||
showPlayerStats: true,
|
||||
showSongInfo: true,
|
||||
},
|
||||
|
||||
setIpAddress(ipAddress: string) {
|
||||
set({
|
||||
ipAddress,
|
||||
});
|
||||
},
|
||||
setAccountId(accountId: string) {
|
||||
set({
|
||||
accountId,
|
||||
});
|
||||
},
|
||||
setPlatform(platform: string) {
|
||||
set({
|
||||
platform,
|
||||
});
|
||||
},
|
||||
setShowPlayerStats(showPlayerStats: boolean) {
|
||||
set({
|
||||
settings: {
|
||||
...get().settings,
|
||||
showPlayerStats,
|
||||
},
|
||||
});
|
||||
},
|
||||
setShowSongInfo(showSongInfo: boolean) {
|
||||
set({
|
||||
settings: {
|
||||
...get().settings,
|
||||
showSongInfo,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "overlaySettings",
|
||||
storage: createJSONStorage(() => IDBStorage),
|
||||
},
|
||||
),
|
||||
);
|
37
src/utils/beatsaver/api.ts
Normal file
37
src/utils/beatsaver/api.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { BeatsaverMap } from "@/schemas/beatsaver/BeatsaverMap";
|
||||
import { ssrSettings } from "@/ssrSettings";
|
||||
import { FetchQueue } from "../fetchWithQueue";
|
||||
import { formatString } from "../string";
|
||||
|
||||
// Create a fetch instance with a cache
|
||||
export const BeatsaverFetchQueue = new FetchQueue();
|
||||
|
||||
// Api endpoints
|
||||
const BS_API_URL = ssrSettings.proxy + "/https://api.beatsaver.com";
|
||||
export const BS_GET_MAP_BY_HASH_URL = BS_API_URL + "/maps/hash/{}";
|
||||
|
||||
/**
|
||||
* Returns the map info for the provided hash
|
||||
*
|
||||
* @param hash the hash of the map
|
||||
* @returns the map info
|
||||
*/
|
||||
async function fetchMapByHash(
|
||||
hash: string,
|
||||
): Promise<BeatsaverMap | undefined | null> {
|
||||
const response = await BeatsaverFetchQueue.fetch(
|
||||
formatString(BS_GET_MAP_BY_HASH_URL, true, hash),
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
// Check if there was an error fetching the user data
|
||||
if (json.error) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return json as BeatsaverMap;
|
||||
}
|
||||
|
||||
export const BeatsaverAPI = {
|
||||
fetchMapByHash,
|
||||
};
|
Reference in New Issue
Block a user