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"
/>
-
-
-
-
-
+
{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 (
<>
-
-
+
+
+
+
{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 (
+
+
+
+
{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 (
+
+
+
+
+
{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,
+};