commit cf9cf06d2b6aab1dee46fd7d36720ccd31f0a54f Author: Michael Zhang Date: Sat Jul 27 16:28:33 2024 -0500 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ba88f51 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "json.schemas": [ + { + "fileMatch": ["manifest.json"], + "url": "https://json.schemastore.org/chrome-manifest" + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cc81ac --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# tweaks + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.1.20. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..5ac9bb4 --- /dev/null +++ b/biome.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "organizeImports": { + "enabled": true + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000..4d78c4e Binary files /dev/null and b/bun.lockb differ diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..f67b2c6 --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..573bb57 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "OsuTweaks", + "version": "0.1.0", + "manifest_version": 2, + "content_scripts": [ + { + "matches": ["*://osu.ppy.sh/*"], + "js": ["src/script.ts"] + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1b6a595 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "tweaks", + "module": "index.ts", + "type": "module", + "scripts": { + "dev": "vite" + }, + "devDependencies": { + "@biomejs/biome": "^1.8.3", + "@types/bun": "latest", + "@types/lodash.isequal": "^4.5.8", + "@types/webextension-polyfill": "^0.10.7", + "vite-plugin-web-extension": "^4.1.6" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "lodash.isequal": "^4.5.0", + "vite": "^5.3.5", + "webextension-polyfill": "^0.12.0" + } +} \ No newline at end of file diff --git a/src/colors.ts b/src/colors.ts new file mode 100644 index 0000000..2486162 --- /dev/null +++ b/src/colors.ts @@ -0,0 +1,170 @@ +// From https://gist.githubusercontent.com/mjackson/5311256/raw/132d6d1f39bf422e03c8ab86b5329d6f4dfbc383/color-conversion-algorithms.js + +/** + * Converts an RGB color value to HSL. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes r, g, and b are contained in the set [0, 255] and + * returns h, s, and l in the set [0, 1]. + * + * @param Number r The red color value + * @param Number g The green color value + * @param Number b The blue color value + * @return Array The HSL representation + */ +export function rgbToHsl(r: number, g: number, b: number) { + (r /= 255), (g /= 255), (b /= 255); + + var max = Math.max(r, g, b), + min = Math.min(r, g, b); + var h, + s, + l = (max + min) / 2; + + if (max == min) { + h = s = 0; // achromatic + } else { + var d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + + h /= 6; + } + + return [h, s, l]; +} + +/** + * Converts an HSL color value to RGB. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes h, s, and l are contained in the set [0, 1] and + * returns r, g, and b in the set [0, 255]. + * + * @param Number h The hue + * @param Number s The saturation + * @param Number l The lightness + * @return Array The RGB representation + */ +export function hslToRgb(h, s, l) { + var r, g, b; + + if (s == 0) { + r = g = b = l; // achromatic + } else { + function hue2rgb(p, q, t) { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + } + + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + + return [r * 255, g * 255, b * 255]; +} + +/** + * Converts an RGB color value to HSV. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSV_color_space. + * Assumes r, g, and b are contained in the set [0, 255] and + * returns h, s, and v in the set [0, 1]. + * + * @param Number r The red color value + * @param Number g The green color value + * @param Number b The blue color value + * @return Array The HSV representation + */ +export function rgbToHsv(r, g, b) { + (r /= 255), (g /= 255), (b /= 255); + + var max = Math.max(r, g, b), + min = Math.min(r, g, b); + var h, + s, + v = max; + + var d = max - min; + s = max == 0 ? 0 : d / max; + + if (max == min) { + h = 0; // achromatic + } else { + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + + h /= 6; + } + + return [h, s, v]; +} + +/** + * Converts an HSV color value to RGB. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSV_color_space. + * Assumes h, s, and v are contained in the set [0, 1] and + * returns r, g, and b in the set [0, 255]. + * + * @param Number h The hue + * @param Number s The saturation + * @param Number v The value + * @return Array The RGB representation + */ +export function hsvToRgb(h, s, v) { + var r, g, b; + + var i = Math.floor(h * 6); + var f = h * 6 - i; + var p = v * (1 - s); + var q = v * (1 - f * s); + var t = v * (1 - (1 - f) * s); + + switch (i % 6) { + case 0: + (r = v), (g = t), (b = p); + break; + case 1: + (r = q), (g = v), (b = p); + break; + case 2: + (r = p), (g = v), (b = t); + break; + case 3: + (r = p), (g = q), (b = v); + break; + case 4: + (r = t), (g = p), (b = v); + break; + case 5: + (r = v), (g = p), (b = q); + break; + } + + return [r * 255, g * 255, b * 255]; +} diff --git a/src/dom.ts b/src/dom.ts new file mode 100644 index 0000000..4d73adb --- /dev/null +++ b/src/dom.ts @@ -0,0 +1,63 @@ +import isEqual from "lodash.isequal"; +const REFRESH_RATE = 1000; + +export async function waitForElementToExist( + selector: string, +): Promise { + let interval: Timer; + return new Promise((resolve) => { + interval = setInterval(() => { + const thing = document.querySelector(selector); + if (thing) { + clearInterval(interval); + resolve(thing); + } + }, REFRESH_RATE); + }); +} + +function onDocumentReady(func: () => void) { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", func); + } else { + func(); + } +} + +export interface OsuPath { + pathname: string; + hash: string; +} + +export function addPageChangeHandler(func: (path: OsuPath) => void) { + onDocumentReady(() => { + console.log("Page loaded."); + + // detects which page we're on + // detect page change, ripped from osuplus + // this is really ugly but whatever + // spent an hour trying to jack some addEventListener calls with no success + + let currentPath: OsuPath = { + pathname: location.pathname, + hash: location.hash, + }; + + setInterval(() => { + const newPath = { + pathname: location.pathname, + hash: location.hash, + }; + + if (!isEqual(currentPath, newPath)) { + func(newPath); + currentPath = newPath; + } + }, REFRESH_RATE); + + // the very first time the browser is opened, handle the page visit + func(currentPath); + }); + + console.log("Isntallled event listener"); +} diff --git a/src/pages/beatmap.ts b/src/pages/beatmap.ts new file mode 100644 index 0000000..ccb0454 --- /dev/null +++ b/src/pages/beatmap.ts @@ -0,0 +1,104 @@ +import { hslToRgb, rgbToHsl } from "../colors"; +import { waitForElementToExist, type OsuPath } from "../dom"; +import { parseCssColor } from "../utils"; + +const MODE_HANDLER = /(?osu|taiko|fruits|mania)\/(?\d+)/; + +export async function beatmapPageHandler(m: RegExpMatchArray, path: OsuPath) { + const m2 = (path.hash ?? "").match(MODE_HANDLER); + const modeString = m2[1]; + + // get the beatmapset data + // TODO: error checking? + const dataEl = document.getElementById("json-beatmapset"); + const beatmapsetData = JSON.parse(dataEl?.textContent ?? "{}"); + + // remove existing difficulty name indicator + waitForElementToExist(".beatmapset-header__diff-name").then( + (diffNameIndicator) => { + diffNameIndicator.parentNode?.removeChild(diffNameIndicator); + }, + ); + + // const starRatingIndicator = await waitForElementToExist( + // ".beatmapset-header__star-difficulty", + // ); + + // starRatingIndicator.parentNode.removeChild(starRatingIndicator); + + // put the names into the bubbles + const beatmapMap = new Map(); + for (const beatmap of [ + ...beatmapsetData.beatmaps, + ...beatmapsetData.converts, + ]) + beatmapMap.set(JSON.stringify([beatmap.id, beatmap.mode]), beatmap); + + const beatmapPicker = await waitForElementToExist( + ".beatmapset-beatmap-picker", + ); + const beatmapPickerChildren: HTMLAnchorElement[] = Array.from( + beatmapPicker.children, + ); + for (const child of beatmapPickerChildren) { + if (!child.href) continue; + + const targetBeatmapId = Number.parseInt(child.href.split("/").at(-1)); + const correspondingBeatmap = beatmapMap.get( + JSON.stringify([targetBeatmapId, modeString]), + ); + + // create the elements and add it to screen + const icon = child.children[0]; + + const container = document.createElement("div"); + container.style.display = "flex"; + container.style.flexDirection = "column"; + container.style.gap = "0"; + container.style.transform = "translateZ(1px)"; + + const diffNameContainer = document.createElement("div"); + const diffNameNode = document.createTextNode(correspondingBeatmap.version); + diffNameContainer.appendChild(diffNameNode); + const colorString = icon.style.getPropertyValue("--diff"); + const color = parseCssColor(colorString); + const hsl = rgbToHsl(...color); + // make sure the colors aren't too dim + if (hsl[2] < 0.5) hsl[2] = 0.5; + hsl[2] *= 1.5; + if (hsl[2] > 1) hsl[2] = 1; + let [r, g, b] = hslToRgb(...hsl); + diffNameContainer.style.lineHeight = "0.75"; + child.style.color = `rgb(${r}, ${g}, ${b})`; + diffNameContainer.style.textShadow = "0 1px 3px rgba(0,0,0,.75)"; + container.appendChild(diffNameContainer); + + const starRating = correspondingBeatmap.difficulty_rating.toLocaleString( + "en-US", + { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + useGrouping: false, + }, + ); + const infoContainer = document.createElement("small"); + const infoNode = document.createTextNode(`${starRating}★`); + infoContainer.style.color = `rgb(${r}, ${g}, ${b})`; + infoContainer.style.textShadow = "0 1px 3px rgba(0,0,0,.75)"; + infoContainer.style.display = "block"; + infoContainer.style.fontSize = "75%"; + infoContainer.appendChild(infoNode); + + child.style.opacity = "1"; + child.style.width = "auto"; + child.style.height = "auto"; + child.style.display = "flex"; + child.style.alignItems = "center"; + child.style.paddingLeft = "8px"; + child.style.paddingRight = "12px"; + child.style.gap = "6px"; + container.appendChild(infoContainer); + + child.replaceChildren(icon, container); + } +} diff --git a/src/pages/user.ts b/src/pages/user.ts new file mode 100644 index 0000000..c8cb014 --- /dev/null +++ b/src/pages/user.ts @@ -0,0 +1,8 @@ +export async function userPageHandler(m, path) { + console.log("User page"); + let currentUser = await wait( + () => unsafeWindow.currentUser, + (c) => !!c, + ); + console.log(currentUser); +} diff --git a/src/script.ts b/src/script.ts new file mode 100644 index 0000000..2efb747 --- /dev/null +++ b/src/script.ts @@ -0,0 +1,27 @@ +import { addPageChangeHandler, type OsuPath } from "./dom"; +import { beatmapPageHandler } from "./pages/beatmap"; +import { userPageHandler } from "./pages/user"; + +console.log("OsuTweaks Extension"); + +const pathHandlers: [ + RegExp, + (m: RegExpMatchArray, _: OsuPath) => Promise, +][] = [ + [/\/users\/(\d+)(?\/(osu|taiko|fruits|mania).+)?/, userPageHandler], + [/\/beatmapsets\/(?\d+).?/, beatmapPageHandler], +]; + +addPageChangeHandler((path) => { + console.log("Changed to page", path); + + const { pathname } = path; + for (const [regex, callback] of pathHandlers) { + const m = pathname.match(regex); + if (m !== null) { + // a match was found + callback(m, path); + break; + } + } +}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..29aa586 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,12 @@ +// Color parsing function +// minified, original is from https://stackoverflow.com/a/68580275 +export function parseCssColor(str: string): [number, number, number] { + const div = document.createElement("div"); + document.body.appendChild(div); + div.style.color = str; + const res = getComputedStyle(div) + .color.match(/[\.\d]+/g) + .map(Number); + div.remove(); + return res; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c2f05fa --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; +import webExtension from "vite-plugin-web-extension"; + +export default defineConfig({ + plugins: [webExtension({ + disableAutoLaunch: true, + browser: process.env.TARGET ?? "firefox" + })], +}); \ No newline at end of file