diff --git a/next.config.js b/next.config.js index f7fdf7a..24c8c9b 100644 --- a/next.config.js +++ b/next.config.js @@ -31,6 +31,12 @@ const nextConfig = { port: "", pathname: "/**", }, + { + protocol: "https", + hostname: "eu.cdn.beatsaver.com", + port: "", + pathname: "/**", + }, ], }, }; diff --git a/package-lock.json b/package-lock.json index 4a1e618..e8503d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,13 @@ "dependencies": { "@boiseitguru/cookie-cutter": "^0.2.1", "@heroicons/react": "^2.0.18", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", "@sentry/nextjs": "^7.74.1", "bluebird": "^3.7.2", @@ -35,6 +38,7 @@ "sharp": "^0.32.6", "tailwind-merge": "^2.0.0", "tailwindcss-animate": "^1.0.7", + "websocket": "^1.0.34", "zustand": "^4.4.3" }, "devDependencies": { @@ -43,6 +47,7 @@ "@types/node-fetch-cache": "^3.0.3", "@types/react": "^18", "@types/react-dom": "^18", + "@types/websocket": "^1.0.8", "autoprefixer": "^10.4.16", "cross-env": "^7.0.3", "eslint": "^8", @@ -695,6 +700,29 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.0.2.tgz", + "integrity": "sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz", @@ -834,6 +862,69 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz", + "integrity": "sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", + "integrity": "sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz", @@ -908,6 +999,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.0.3.tgz", + "integrity": "sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", @@ -1483,6 +1603,15 @@ "integrity": "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==", "devOptional": true }, + "node_modules/@types/websocket": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/websocket/-/websocket-1.0.8.tgz", + "integrity": "sha512-wvkOpWApbuxVfHhSQ1XrjVN4363vsfLJwEo4AboIZk0g1vJA5nmLp8GXUHuIdf4/Fe7+/V0Efe2HvWiLqHtlqw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/parser": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.8.0.tgz", @@ -2076,6 +2205,18 @@ "ieee754": "^1.1.13" } }, + "node_modules/bufferutil": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", + "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", + "hasInstallScript": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -2372,6 +2513,15 @@ "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", "devOptional": true }, + "node_modules/d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "dependencies": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2695,6 +2845,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es5-ext": { + "version": "0.10.62", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", + "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", + "hasInstallScript": true, + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", + "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", + "dependencies": { + "d": "^1.0.1", + "ext": "^1.1.2" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -3148,6 +3331,19 @@ "node": ">=6" } }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/ext/node_modules/type": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", + "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4072,6 +4268,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + }, "node_modules/is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -4597,6 +4798,11 @@ "react-dom": "*" } }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" + }, "node_modules/node-abi": { "version": "3.51.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.51.0.tgz", @@ -4661,6 +4867,16 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/node-gyp-build": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.1.tgz", + "integrity": "sha512-24vnklJmyRS8ViBNI8KbtK/r/DmXQMRiOMXTNz2nrTnAYUwjmEEbnnpB/+kt+yWRv73bPsSPRFddrcIbAxSiMQ==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", @@ -6310,6 +6526,11 @@ "node": "*" } }, + "node_modules/type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", + "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6399,6 +6620,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, "node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -6537,6 +6766,18 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6594,6 +6835,35 @@ "node": ">=10.13.0" } }, + "node_modules/websocket": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", + "integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.50", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/websocket/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/websocket/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6710,6 +6980,14 @@ } } }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "engines": { + "node": ">=0.10.32" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index b92e1db..790e40c 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,13 @@ "dependencies": { "@boiseitguru/cookie-cutter": "^0.2.1", "@heroicons/react": "^2.0.18", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", "@sentry/nextjs": "^7.74.1", "bluebird": "^3.7.2", @@ -36,6 +39,7 @@ "sharp": "^0.32.6", "tailwind-merge": "^2.0.0", "tailwindcss-animate": "^1.0.7", + "websocket": "^1.0.34", "zustand": "^4.4.3" }, "devDependencies": { @@ -44,6 +48,7 @@ "@types/node-fetch-cache": "^3.0.3", "@types/react": "^18", "@types/react-dom": "^18", + "@types/websocket": "^1.0.8", "autoprefixer": "^10.4.16", "cross-env": "^7.0.3", "eslint": "^8", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 89f8810..7ecbbb8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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" /> - -
- Background image -
- + {children} diff --git a/src/app/overlay/builder/page.tsx b/src/app/overlay/builder/page.tsx new file mode 100644 index 0000000..159f923 --- /dev/null +++ b/src/app/overlay/builder/page.tsx @@ -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 ( +
+ + + +

Overlay Builder

+
+ + +

+ Confused on how to use this? Check out the{" "} + + + tutorial + + + . +

+ + { + settingsStore.setIpAddress(e); + }} + /> + { + settingsStore.setAccountId(e); + }} + /> + + { + settingsStore.setPlatform(value); + }} + /> + +
+ + { + settingsStore.setShowPlayerStats(value); + }} + /> + { + settingsStore.setShowSongInfo(value); + }} + /> +
+ + +
+
+
+
+ ); +} diff --git a/src/app/overlay/page.tsx b/src/app/overlay/page.tsx new file mode 100644 index 0000000..06fc7a1 --- /dev/null +++ b/src/app/overlay/page.tsx @@ -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 { + 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 ( +
+ +

Loading player data

+
+ ); + } + + if (!this.state.settings) { + return ( +
+ + + Overlay + +

+ This page is meant to be used as an overlay for streaming. +

+

+ To generate an overlay, go to the builder{" "} + + here + + . +

+
+
+
+
+ ); + } + + return ( +
+
+ + +
+
+ +
+
+ ); + } +} diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index a66a121..19ab442 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -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 ( - {props.children} + + + {props.children} + ); } diff --git a/src/components/Container.tsx b/src/components/Container.tsx index 665514b..14f4730 100644 --- a/src/components/Container.tsx +++ b/src/components/Container.tsx @@ -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 ( <> - -
+
+ Background image +
+
{children}
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index c4eeb6e..b71b166 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -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={} href="/ranking/global/1" /> + } + href="/overlay/builder" + /> void; +}; + +export function Input({ label, id, defaultValue, onChange }: InputProps) { + return ( + <> + + { + onChange && onChange(e.target.value); + }} + /> + + ); +} diff --git a/src/components/input/RadioInput.tsx b/src/components/input/RadioInput.tsx new file mode 100644 index 0000000..8bab64e --- /dev/null +++ b/src/components/input/RadioInput.tsx @@ -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 ( +
+ {id && label && } + onChange && onChange(value)} + > + {items.map((item, index) => { + return ( +
+ + {item.value} + + +
+ ); + })} +
+
+ ); +} diff --git a/src/components/input/SwitchInput.tsx b/src/components/input/SwitchInput.tsx new file mode 100644 index 0000000..f6720a1 --- /dev/null +++ b/src/components/input/SwitchInput.tsx @@ -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 ( +
+ onChange && onChange(value)} + /> + +
+ ); +} diff --git a/src/components/overlay/PlayerStats.tsx b/src/components/overlay/PlayerStats.tsx new file mode 100644 index 0000000..d9c186e --- /dev/null +++ b/src/components/overlay/PlayerStats.tsx @@ -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 ( +
+ Player profile picture +
+

{formatNumber(player.pp, 2)}pp

+
+ +

#{formatNumber(player.rank)}

+
+
+ +

#{formatNumber(player.countryRank)}

+
+
+
+ ); +} diff --git a/src/components/overlay/ScoreStats.tsx b/src/components/overlay/ScoreStats.tsx new file mode 100644 index 0000000..66bdd69 --- /dev/null +++ b/src/components/overlay/ScoreStats.tsx @@ -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 ( +
+

{formatNumber(scoreStats.score)}

+

Combo: {formatNumber(scoreStats.combo)}

+

+ {scoreStats.rank} {scoreStats.accuracy.toFixed(2)}% +

+
+ ); +} diff --git a/src/components/overlay/SongInfo.tsx b/src/components/overlay/SongInfo.tsx new file mode 100644 index 0000000..87d6e40 --- /dev/null +++ b/src/components/overlay/SongInfo.tsx @@ -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 ( +
+ Song Image +
+
+

{songInfo.songName}

+

{songInfo.songMapper}

+
+
+

+ {songInfo.difficulty} +

+

!bsr {songInfo.bsr}

+
+
+
+ ); +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index b4e2b8d..24d74df 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -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 >(({ className, ...props }, ref) => (
-)) -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, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..c7526d9 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/utils/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..160a55f --- /dev/null +++ b/src/components/ui/label.tsx @@ -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, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..6278649 --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..f5bc037 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/src/overlay/httpSiraStatus.ts b/src/overlay/httpSiraStatus.ts new file mode 100644 index 0000000..15a1803 --- /dev/null +++ b/src/overlay/httpSiraStatus.ts @@ -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, +}; diff --git a/src/schemas/beatsaver/BeatsaverMap.ts b/src/schemas/beatsaver/BeatsaverMap.ts new file mode 100644 index 0000000..b9639ef --- /dev/null +++ b/src/schemas/beatsaver/BeatsaverMap.ts @@ -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; +}; diff --git a/src/schemas/beatsaver/BeatsaverMapDifficulty.ts b/src/schemas/beatsaver/BeatsaverMapDifficulty.ts new file mode 100644 index 0000000..91e9769 --- /dev/null +++ b/src/schemas/beatsaver/BeatsaverMapDifficulty.ts @@ -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; +}; diff --git a/src/schemas/beatsaver/BeatsaverMapMetadata.ts b/src/schemas/beatsaver/BeatsaverMapMetadata.ts new file mode 100644 index 0000000..3afdb44 --- /dev/null +++ b/src/schemas/beatsaver/BeatsaverMapMetadata.ts @@ -0,0 +1,8 @@ +export type BeatsaverMapMetadata = { + bpm: number; + duration: number; + songName: string; + songSubName: string; + songAuthorName: string; + levelAuthorName: string; +}; diff --git a/src/schemas/beatsaver/BeatsaverMapStats.ts b/src/schemas/beatsaver/BeatsaverMapStats.ts new file mode 100644 index 0000000..7b933db --- /dev/null +++ b/src/schemas/beatsaver/BeatsaverMapStats.ts @@ -0,0 +1,7 @@ +export type BeatsaverMapStats = { + plays: number; + downloads: number; + upvotes: number; + downvotes: number; + score: number; +}; diff --git a/src/schemas/beatsaver/BeatsaverMapSummary.ts b/src/schemas/beatsaver/BeatsaverMapSummary.ts new file mode 100644 index 0000000..1363288 --- /dev/null +++ b/src/schemas/beatsaver/BeatsaverMapSummary.ts @@ -0,0 +1,5 @@ +export type BeatsaverMapSummary = { + errors: number; + warns: number; + resets: number; +}; diff --git a/src/schemas/beatsaver/BeatsaverMapVersion.ts b/src/schemas/beatsaver/BeatsaverMapVersion.ts new file mode 100644 index 0000000..dab4071 --- /dev/null +++ b/src/schemas/beatsaver/BeatsaverMapVersion.ts @@ -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; +}; diff --git a/src/schemas/beatsaver/BeatsaverUploader.ts b/src/schemas/beatsaver/BeatsaverUploader.ts new file mode 100644 index 0000000..5e1c085 --- /dev/null +++ b/src/schemas/beatsaver/BeatsaverUploader.ts @@ -0,0 +1,10 @@ +export type BeatsaverUploader = { + id: string; + name: string; + hash: string; + avatar: string; + type: string; + admin: boolean; + curator: boolean; + playlistUrl: string; +}; diff --git a/src/store/overlayDataStore.ts b/src/store/overlayDataStore.ts new file mode 100644 index 0000000..08085f1 --- /dev/null +++ b/src/store/overlayDataStore.ts @@ -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()((set, get) => ({ + paused: false, + songInfo: undefined, + scoreStats: undefined, + + setScoreStats(scoreStats: OverlayDataStore["scoreStats"]) { + set({ + scoreStats, + }); + }, + setSongInfo(songInfo: OverlayDataStore["songInfo"]) { + set({ + songInfo, + }); + }, +})); diff --git a/src/store/overlaySettingsStore.ts b/src/store/overlaySettingsStore.ts new file mode 100644 index 0000000..d0f6bac --- /dev/null +++ b/src/store/overlaySettingsStore.ts @@ -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()( + 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), + }, + ), +); diff --git a/src/utils/beatsaver/api.ts b/src/utils/beatsaver/api.ts new file mode 100644 index 0000000..147fa86 --- /dev/null +++ b/src/utils/beatsaver/api.ts @@ -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 { + 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, +};