├── .gitattributes ├── .gitignore ├── .prettierrc ├── README.md ├── bun.lock ├── manifest.json ├── package.json ├── resources └── screenshot.png ├── src ├── RhythmString.tsx ├── app.tsx ├── components │ ├── AnimatedCanvas.tsx │ ├── LoadingIcon.tsx │ └── renderer │ │ ├── DebugVisualizer.tsx │ │ ├── NCSVisualizer.tsx │ │ └── SpectrumVisualizer.tsx ├── css │ ├── app.module.scss │ ├── icon-active.svg │ ├── icon-inactive.svg │ ├── ncs-active.svg │ └── ncs-inactive.svg ├── error.ts ├── menu.tsx ├── metadata.ts ├── protobuf │ ├── ColorResult.ts │ └── defs.ts ├── settings.json ├── shaders │ └── ncs-visualizer │ │ ├── blur.ts │ │ ├── dot.ts │ │ ├── finalize.ts │ │ └── particle.ts ├── types │ ├── css-modules.d.ts │ ├── fastnoise-lite.d.ts │ ├── spicetify.d.ts │ └── types.d.ts ├── utils.ts └── window.tsx └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/* linguist-vendored -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/node 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # Snowpack dependency directory (https://snowpack.dev/) 51 | web_modules/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional stylelint cache 63 | .stylelintcache 64 | 65 | # Microbundle cache 66 | .rpt2_cache/ 67 | .rts2_cache_cjs/ 68 | .rts2_cache_es/ 69 | .rts2_cache_umd/ 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variable files 81 | .env 82 | .env.development.local 83 | .env.test.local 84 | .env.production.local 85 | .env.local 86 | 87 | # parcel-bundler cache (https://parceljs.org/) 88 | .cache 89 | .parcel-cache 90 | 91 | # Next.js build output 92 | .next 93 | out 94 | 95 | # Nuxt.js build / generate output 96 | .nuxt 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | # End of https://www.toptal.com/developers/gitignore/api/node 145 | 146 | dist 147 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": true, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid", 6 | "printWidth": 130 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spicetify Visualizer 2 | formerly NCS Visualizer 3 | 4 | ### Audio Visualizer for Spicetify 5 | 6 | --- 7 | 8 | For help with installing/uninstalling, check out the [Spicetify FAQ](https://spicetify.app/docs/faq) or ask on the [Spicetify Discord](https://discord.gg/VnevqPp2Rr). 9 | If you're experiencing any issues specific to this extension, take a look at the [Issues](https://github.com/Konsl/spicetify-visualizer/issues) tab. 10 | Check if an issue regarding your topic already exists, if not create a new one. 11 | 12 | --- 13 | 14 | ![preview](resources/screenshot.png) 15 | 16 | ### Installation 17 | * Open the Spicetify config directory by running `spicetify config-dir` from the terminal 18 | * Navigate to `CustomApps` and create a folder `visualizer` 19 | * Download the files in the [dist branch](https://github.com/Konsl/spicetify-visualizer/archive/refs/heads/dist.zip) and copy the files (index.js, manifest.json, etc) into the `visualizer` folder 20 | * Add the app to the Spicetify config by running `spicetify config custom_apps visualizer` from the terminal 21 | * Re-apply Spicetify by running `spicetify apply` from the terminal 22 | 23 | ### Updating 24 | * Open the Spicetify config directory by running `spicetify config-dir` from the terminal 25 | * Navigate to `CustomApps` and into the folder `visualizer` 26 | * Download the latest files from the [dist branch](https://github.com/Konsl/spicetify-visualizer/archive/refs/heads/dist.zip) and copy the files (index.js, manifest.json, etc) into the `visualizer` folder 27 | * Re-apply Spicetify by running `spicetify apply` from the terminal 28 | 29 | ### Uninstallation 30 | * Open the Spicetify config directory by running `spicetify config-dir` from the terminal 31 | * Navigate to `CustomApps` and delete the folder `visualizer` 32 | * Remove the app from the Spicetify config by running `spicetify config custom_apps visualizer-` from the terminal 33 | * Re-apply Spicetify by running `spicetify apply` from the terminal 34 | 35 | --- 36 | 37 | ### Updating from old NCS Visualizer 38 | * Open the Spicetify config directory by running `spicetify config-dir` from the terminal 39 | * Navigate to `CustomApps` and delete the folder `ncs-visualizer` 40 | * Remove the app from the Spicetify config by running `spicetify config custom_apps ncs-visualizer-` from the terminal 41 | * Follow the new [Installation instructions](#installation) 42 | 43 | --- 44 | 45 | 46 | If you have further issues you can open a ticket on [Discord](https://discord.gg/appzM48wXG). 47 | [CSS Snippet for Compatibility with Comfy Theme](https://github.com/Konsl/spicetify-visualizer/issues/21#issuecomment-2050515422) 48 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "spicetify-visualizer", 6 | "dependencies": { 7 | "fflate": "^0.8.2", 8 | }, 9 | "devDependencies": { 10 | "@types/react": "^18.3.18", 11 | "@types/react-dom": "^18.3.5", 12 | "prettier": "^3.5.0", 13 | "spicetify-creator": "^1.0.17", 14 | }, 15 | }, 16 | }, 17 | "packages": { 18 | "@adobe/css-tools": ["@adobe/css-tools@4.3.3", "", {}, "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ=="], 19 | 20 | "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.14.54", "", { "os": "linux", "cpu": "none" }, "sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw=="], 21 | 22 | "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], 23 | 24 | "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], 25 | 26 | "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], 27 | 28 | "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="], 29 | 30 | "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="], 31 | 32 | "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="], 33 | 34 | "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="], 35 | 36 | "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="], 37 | 38 | "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="], 39 | 40 | "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="], 41 | 42 | "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="], 43 | 44 | "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="], 45 | 46 | "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="], 47 | 48 | "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="], 49 | 50 | "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], 51 | 52 | "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], 53 | 54 | "@types/prop-types": ["@types/prop-types@15.7.14", "", {}, "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="], 55 | 56 | "@types/react": ["@types/react@18.3.18", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ=="], 57 | 58 | "@types/react-dom": ["@types/react-dom@18.3.5", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q=="], 59 | 60 | "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], 61 | 62 | "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 63 | 64 | "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], 65 | 66 | "autoprefixer": ["autoprefixer@10.4.20", "", { "dependencies": { "browserslist": "^4.23.3", "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g=="], 67 | 68 | "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], 69 | 70 | "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], 71 | 72 | "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], 73 | 74 | "browserslist": ["browserslist@4.24.4", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A=="], 75 | 76 | "caniuse-lite": ["caniuse-lite@1.0.30001699", "", {}, "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w=="], 77 | 78 | "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 79 | 80 | "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], 81 | 82 | "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], 83 | 84 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 85 | 86 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 87 | 88 | "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], 89 | 90 | "copy-anything": ["copy-anything@2.0.6", "", { "dependencies": { "is-what": "^3.14.1" } }, "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw=="], 91 | 92 | "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 93 | 94 | "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], 95 | 96 | "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 97 | 98 | "cwd": ["cwd@0.10.0", "", { "dependencies": { "find-pkg": "^0.1.2", "fs-exists-sync": "^0.1.0" } }, "sha512-YGZxdTTL9lmLkCUTpg4j0zQ7IhRB5ZmqNBbGCl3Tg6MP/d5/6sY7L5mmTjzbc6JKgVZYiqTQTNhPFsbXNGlRaA=="], 99 | 100 | "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 101 | 102 | "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], 103 | 104 | "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], 105 | 106 | "electron-to-chromium": ["electron-to-chromium@1.5.97", "", {}, "sha512-HKLtaH02augM7ZOdYRuO19rWDeY+QSJ1VxnXFa/XDFLf07HvM90pALIJFgrO+UVaajI3+aJMMpojoUTLZyQ7JQ=="], 107 | 108 | "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], 109 | 110 | "errno": ["errno@0.1.8", "", { "dependencies": { "prr": "~1.0.1" }, "bin": { "errno": "cli.js" } }, "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A=="], 111 | 112 | "esbuild": ["esbuild@0.14.54", "", { "optionalDependencies": { "@esbuild/linux-loong64": "0.14.54", "esbuild-android-64": "0.14.54", "esbuild-android-arm64": "0.14.54", "esbuild-darwin-64": "0.14.54", "esbuild-darwin-arm64": "0.14.54", "esbuild-freebsd-64": "0.14.54", "esbuild-freebsd-arm64": "0.14.54", "esbuild-linux-32": "0.14.54", "esbuild-linux-64": "0.14.54", "esbuild-linux-arm": "0.14.54", "esbuild-linux-arm64": "0.14.54", "esbuild-linux-mips64le": "0.14.54", "esbuild-linux-ppc64le": "0.14.54", "esbuild-linux-riscv64": "0.14.54", "esbuild-linux-s390x": "0.14.54", "esbuild-netbsd-64": "0.14.54", "esbuild-openbsd-64": "0.14.54", "esbuild-sunos-64": "0.14.54", "esbuild-windows-32": "0.14.54", "esbuild-windows-64": "0.14.54", "esbuild-windows-arm64": "0.14.54" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA=="], 113 | 114 | "esbuild-android-64": ["esbuild-android-64@0.14.54", "", { "os": "android", "cpu": "x64" }, "sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ=="], 115 | 116 | "esbuild-android-arm64": ["esbuild-android-arm64@0.14.54", "", { "os": "android", "cpu": "arm64" }, "sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg=="], 117 | 118 | "esbuild-darwin-64": ["esbuild-darwin-64@0.14.54", "", { "os": "darwin", "cpu": "x64" }, "sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug=="], 119 | 120 | "esbuild-darwin-arm64": ["esbuild-darwin-arm64@0.14.54", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw=="], 121 | 122 | "esbuild-freebsd-64": ["esbuild-freebsd-64@0.14.54", "", { "os": "freebsd", "cpu": "x64" }, "sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg=="], 123 | 124 | "esbuild-freebsd-arm64": ["esbuild-freebsd-arm64@0.14.54", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q=="], 125 | 126 | "esbuild-linux-32": ["esbuild-linux-32@0.14.54", "", { "os": "linux", "cpu": "ia32" }, "sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw=="], 127 | 128 | "esbuild-linux-64": ["esbuild-linux-64@0.14.54", "", { "os": "linux", "cpu": "x64" }, "sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg=="], 129 | 130 | "esbuild-linux-arm": ["esbuild-linux-arm@0.14.54", "", { "os": "linux", "cpu": "arm" }, "sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw=="], 131 | 132 | "esbuild-linux-arm64": ["esbuild-linux-arm64@0.14.54", "", { "os": "linux", "cpu": "arm64" }, "sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig=="], 133 | 134 | "esbuild-linux-mips64le": ["esbuild-linux-mips64le@0.14.54", "", { "os": "linux", "cpu": "none" }, "sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw=="], 135 | 136 | "esbuild-linux-ppc64le": ["esbuild-linux-ppc64le@0.14.54", "", { "os": "linux", "cpu": "ppc64" }, "sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ=="], 137 | 138 | "esbuild-linux-riscv64": ["esbuild-linux-riscv64@0.14.54", "", { "os": "linux", "cpu": "none" }, "sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg=="], 139 | 140 | "esbuild-linux-s390x": ["esbuild-linux-s390x@0.14.54", "", { "os": "linux", "cpu": "s390x" }, "sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA=="], 141 | 142 | "esbuild-netbsd-64": ["esbuild-netbsd-64@0.14.54", "", { "os": "none", "cpu": "x64" }, "sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w=="], 143 | 144 | "esbuild-openbsd-64": ["esbuild-openbsd-64@0.14.54", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw=="], 145 | 146 | "esbuild-plugin-external-global": ["esbuild-plugin-external-global@1.0.1", "", {}, "sha512-NDzYHRoShpvLqNcrgV8ZQh61sMIFAry5KLTQV83BPG5iTXCCu7h72SCfJ97bW0GqtuqDD/1aqLbKinI/rNgUsg=="], 147 | 148 | "esbuild-plugin-postcss2": ["esbuild-plugin-postcss2@0.1.1", "", { "dependencies": { "autoprefixer": "^10.2.5", "fs-extra": "^9.1.0", "less": "^4.x", "postcss": "8.x", "postcss-modules": "^4.0.0", "resolve-file": "^0.3.0", "sass": "^1.x", "stylus": "^0.x", "tmp": "^0.2.1" } }, "sha512-BMHnOTfZo+ghrzYnBtcXlHuMpOEwfTFarhGG4o6mivzPZGUXzeMn/hdFhtRpmCKzUvXsaxnZ3N72xA8CwdtZ3w=="], 149 | 150 | "esbuild-sunos-64": ["esbuild-sunos-64@0.14.54", "", { "os": "sunos", "cpu": "x64" }, "sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw=="], 151 | 152 | "esbuild-windows-32": ["esbuild-windows-32@0.14.54", "", { "os": "win32", "cpu": "ia32" }, "sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w=="], 153 | 154 | "esbuild-windows-64": ["esbuild-windows-64@0.14.54", "", { "os": "win32", "cpu": "x64" }, "sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ=="], 155 | 156 | "esbuild-windows-arm64": ["esbuild-windows-arm64@0.14.54", "", { "os": "win32", "cpu": "arm64" }, "sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg=="], 157 | 158 | "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 159 | 160 | "expand-tilde": ["expand-tilde@2.0.2", "", { "dependencies": { "homedir-polyfill": "^1.0.1" } }, "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw=="], 161 | 162 | "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], 163 | 164 | "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], 165 | 166 | "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], 167 | 168 | "find-file-up": ["find-file-up@0.1.3", "", { "dependencies": { "fs-exists-sync": "^0.1.0", "resolve-dir": "^0.1.0" } }, "sha512-mBxmNbVyjg1LQIIpgO8hN+ybWBgDQK8qjht+EbrTCGmmPV/sc7RF1i9stPTD6bpvXZywBdrwRYxhSdJv867L6A=="], 169 | 170 | "find-pkg": ["find-pkg@0.1.2", "", { "dependencies": { "find-file-up": "^0.1.2" } }, "sha512-0rnQWcFwZr7eO0513HahrWafsc3CTFioEB7DRiEYCUM/70QXSY8f3mCST17HXLcPvEhzH/Ty/Bxd72ZZsr/yvw=="], 171 | 172 | "foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="], 173 | 174 | "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], 175 | 176 | "fs-exists-sync": ["fs-exists-sync@0.1.0", "", {}, "sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg=="], 177 | 178 | "fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], 179 | 180 | "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], 181 | 182 | "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 183 | 184 | "generic-names": ["generic-names@4.0.0", "", { "dependencies": { "loader-utils": "^3.2.0" } }, "sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A=="], 185 | 186 | "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], 187 | 188 | "global-modules": ["global-modules@0.2.3", "", { "dependencies": { "global-prefix": "^0.1.4", "is-windows": "^0.2.0" } }, "sha512-JeXuCbvYzYXcwE6acL9V2bAOeSIGl4dD+iwLY9iUx2VBJJ80R18HCn+JCwHM9Oegdfya3lEkGCdaRkSyc10hDA=="], 189 | 190 | "global-prefix": ["global-prefix@0.1.5", "", { "dependencies": { "homedir-polyfill": "^1.0.0", "ini": "^1.3.4", "is-windows": "^0.2.0", "which": "^1.2.12" } }, "sha512-gOPiyxcD9dJGCEArAhF4Hd0BAqvAe/JzERP7tYumE4yIkmIedPUVXcJFWbV3/p/ovIIvKjkrTk+f1UVkq7vvbw=="], 191 | 192 | "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], 193 | 194 | "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], 195 | 196 | "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], 197 | 198 | "homedir-polyfill": ["homedir-polyfill@1.0.3", "", { "dependencies": { "parse-passwd": "^1.0.0" } }, "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA=="], 199 | 200 | "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], 201 | 202 | "icss-replace-symbols": ["icss-replace-symbols@1.1.0", "", {}, "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg=="], 203 | 204 | "icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="], 205 | 206 | "image-size": ["image-size@0.5.5", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ=="], 207 | 208 | "immutable": ["immutable@5.0.3", "", {}, "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw=="], 209 | 210 | "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], 211 | 212 | "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 213 | 214 | "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], 215 | 216 | "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], 217 | 218 | "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], 219 | 220 | "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], 221 | 222 | "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 223 | 224 | "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 225 | 226 | "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 227 | 228 | "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], 229 | 230 | "is-what": ["is-what@3.14.1", "", {}, "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA=="], 231 | 232 | "is-windows": ["is-windows@0.2.0", "", {}, "sha512-n67eJYmXbniZB7RF4I/FTjK1s6RPOCTxhYrVYLRaCt3lF0mpWZPKr3T2LSZAqyjQsxR2qMmGYXXzK0YWwcPM1Q=="], 233 | 234 | "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 235 | 236 | "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], 237 | 238 | "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], 239 | 240 | "kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], 241 | 242 | "lazy-cache": ["lazy-cache@2.0.2", "", { "dependencies": { "set-getter": "^0.1.0" } }, "sha512-7vp2Acd2+Kz4XkzxGxaB1FWOi8KjWIWsgdfD5MCb86DWvlLqhRPM+d6Pro3iNEL5VT9mstz5hKAlcd+QR6H3aA=="], 243 | 244 | "less": ["less@4.2.2", "", { "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", "tslib": "^2.3.0" }, "optionalDependencies": { "errno": "^0.1.1", "graceful-fs": "^4.1.2", "image-size": "~0.5.0", "make-dir": "^2.1.0", "mime": "^1.4.1", "needle": "^3.1.0", "source-map": "~0.6.0" }, "bin": { "lessc": "bin/lessc" } }, "sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg=="], 245 | 246 | "loader-utils": ["loader-utils@3.3.1", "", {}, "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg=="], 247 | 248 | "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], 249 | 250 | "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], 251 | 252 | "make-dir": ["make-dir@2.1.0", "", { "dependencies": { "pify": "^4.0.1", "semver": "^5.6.0" } }, "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA=="], 253 | 254 | "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], 255 | 256 | "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], 257 | 258 | "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], 259 | 260 | "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], 261 | 262 | "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], 263 | 264 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 265 | 266 | "nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="], 267 | 268 | "needle": ["needle@3.3.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" }, "bin": { "needle": "bin/needle" } }, "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q=="], 269 | 270 | "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], 271 | 272 | "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], 273 | 274 | "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], 275 | 276 | "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], 277 | 278 | "os-homedir": ["os-homedir@1.0.2", "", {}, "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ=="], 279 | 280 | "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], 281 | 282 | "parse-node-version": ["parse-node-version@1.0.1", "", {}, "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA=="], 283 | 284 | "parse-passwd": ["parse-passwd@1.0.0", "", {}, "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q=="], 285 | 286 | "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], 287 | 288 | "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 289 | 290 | "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 291 | 292 | "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], 293 | 294 | "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 295 | 296 | "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 297 | 298 | "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], 299 | 300 | "postcss": ["postcss@8.5.2", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA=="], 301 | 302 | "postcss-modules": ["postcss-modules@4.3.1", "", { "dependencies": { "generic-names": "^4.0.0", "icss-replace-symbols": "^1.1.0", "lodash.camelcase": "^4.3.0", "postcss-modules-extract-imports": "^3.0.0", "postcss-modules-local-by-default": "^4.0.0", "postcss-modules-scope": "^3.0.0", "postcss-modules-values": "^4.0.0", "string-hash": "^1.1.1" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q=="], 303 | 304 | "postcss-modules-extract-imports": ["postcss-modules-extract-imports@3.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q=="], 305 | 306 | "postcss-modules-local-by-default": ["postcss-modules-local-by-default@4.2.0", "", { "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.1.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw=="], 307 | 308 | "postcss-modules-scope": ["postcss-modules-scope@3.2.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA=="], 309 | 310 | "postcss-modules-values": ["postcss-modules-values@4.0.0", "", { "dependencies": { "icss-utils": "^5.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ=="], 311 | 312 | "postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="], 313 | 314 | "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 315 | 316 | "prettier": ["prettier@3.5.0", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-quyMrVt6svPS7CjQ9gKb3GLEX/rl3BCL2oa/QkNcXv4YNVBC9olt3s+H7ukto06q7B1Qz46PbrKLO34PR6vXcA=="], 317 | 318 | "prr": ["prr@1.0.1", "", {}, "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw=="], 319 | 320 | "readdirp": ["readdirp@4.1.1", "", {}, "sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw=="], 321 | 322 | "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], 323 | 324 | "resolve-dir": ["resolve-dir@0.1.1", "", { "dependencies": { "expand-tilde": "^1.2.2", "global-modules": "^0.2.3" } }, "sha512-QxMPqI6le2u0dCLyiGzgy92kjkkL6zO0XyvHzjdTNH3zM6e5Hz3BwG6+aEyNgiQ5Xz6PwTwgQEj3U50dByPKIA=="], 325 | 326 | "resolve-file": ["resolve-file@0.3.0", "", { "dependencies": { "cwd": "^0.10.0", "expand-tilde": "^2.0.2", "extend-shallow": "^2.0.1", "fs-exists-sync": "^0.1.0", "homedir-polyfill": "^1.0.1", "lazy-cache": "^2.0.2", "resolve": "^1.2.0" } }, "sha512-9RXicAgDvLD272hZ3HwJv9MJUGxCBRRwwSBRdOGWgcO03MtC9UTGC6XG1VbS4T5MvDrb+tVZx2RhZ90uk3uczg=="], 327 | 328 | "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], 329 | 330 | "sass": ["sass@1.84.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-XDAbhEPJRxi7H0SxrnOpiXFQoUJHwkR2u3Zc4el+fK/Tt5Hpzw5kkQ59qVDfvdaUq6gCrEZIbySFBM2T9DNKHg=="], 331 | 332 | "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], 333 | 334 | "semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], 335 | 336 | "set-getter": ["set-getter@0.1.1", "", { "dependencies": { "to-object-path": "^0.3.0" } }, "sha512-9sVWOy+gthr+0G9DzqqLaYNA7+5OKkSmcqjL9cBpDEaZrr3ShQlyX2cZ/O/ozE41oxn/Tt0LGEM/w4Rub3A3gw=="], 337 | 338 | "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], 339 | 340 | "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 341 | 342 | "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], 343 | 344 | "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 345 | 346 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 347 | 348 | "spicetify-creator": ["spicetify-creator@1.0.17", "", { "dependencies": { "chalk": "^4.1.2", "clean-css": "^5.2.4", "esbuild": "^0.14.13", "esbuild-plugin-external-global": "^1.0.1", "esbuild-plugin-postcss2": "0.1.1", "glob": "^7.2.0", "minimist": "^1.2.5", "uglify-js": "^3.15.1" }, "bin": { "spicetify-creator": "dist/index.js" } }, "sha512-PajJIP0mi8UPErSeuqDf2wF4j8aHF4O+1S9JDIuY4wYKcHHWSvQ21FGnfyxzO6rS4/MkwMDzO6uERnrNAu+sHw=="], 349 | 350 | "string-hash": ["string-hash@1.1.3", "", {}, "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A=="], 351 | 352 | "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], 353 | 354 | "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 355 | 356 | "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], 357 | 358 | "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 359 | 360 | "stylus": ["stylus@0.64.0", "", { "dependencies": { "@adobe/css-tools": "~4.3.3", "debug": "^4.3.2", "glob": "^10.4.5", "sax": "~1.4.1", "source-map": "^0.7.3" }, "bin": { "stylus": "bin/stylus" } }, "sha512-ZIdT8eUv8tegmqy1tTIdJv9We2DumkNZFdCF5mz/Kpq3OcTaxSuCAYZge6HKK2CmNC02G1eJig2RV7XTw5hQrA=="], 361 | 362 | "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 363 | 364 | "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 365 | 366 | "tmp": ["tmp@0.2.3", "", {}, "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w=="], 367 | 368 | "to-object-path": ["to-object-path@0.3.0", "", { "dependencies": { "kind-of": "^3.0.2" } }, "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg=="], 369 | 370 | "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 371 | 372 | "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 373 | 374 | "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], 375 | 376 | "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], 377 | 378 | "update-browserslist-db": ["update-browserslist-db@1.1.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg=="], 379 | 380 | "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], 381 | 382 | "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 383 | 384 | "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], 385 | 386 | "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], 387 | 388 | "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], 389 | 390 | "global-prefix/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], 391 | 392 | "resolve-dir/expand-tilde": ["expand-tilde@1.2.2", "", { "dependencies": { "os-homedir": "^1.0.1" } }, "sha512-rtmc+cjLZqnu9dSYosX9EWmSJhTwpACgJQTfj4hgg2JjOD/6SIQalZrt4a3aQeh++oNxkazcaxrhPUj6+g5G/Q=="], 393 | 394 | "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 395 | 396 | "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 397 | 398 | "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 399 | 400 | "stylus/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], 401 | 402 | "stylus/source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], 403 | 404 | "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], 405 | 406 | "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 407 | 408 | "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 409 | 410 | "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 411 | 412 | "stylus/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 413 | 414 | "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 415 | 416 | "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 417 | 418 | "stylus/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], 419 | } 420 | } 421 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Visualizer", 3 | "description": "Audio Visualizer for Spicetify", 4 | "preview": "resources/screenshot.png", 5 | "readme": "README.md", 6 | "authors": [ 7 | { "name": "Konsl", "url": "https://github.com/Konsl" } 8 | ], 9 | "tags": ["visualizer"] 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spicetify-visualizer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "spicetify-creator", 7 | "build-local": "spicetify-creator --out=dist --minify", 8 | "watch": "spicetify-creator --watch", 9 | "format": "prettier --write src/" 10 | }, 11 | "license": "MIT", 12 | "devDependencies": { 13 | "@types/react": "^18.3.18", 14 | "@types/react-dom": "^18.3.5", 15 | "prettier": "^3.5.0", 16 | "spicetify-creator": "^1.0.17" 17 | }, 18 | "dependencies": { 19 | "fflate": "^0.8.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /resources/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Konsl/spicetify-visualizer/21ec54030c8cc2bdf0de395326594b37b2f4dbd4/resources/screenshot.png -------------------------------------------------------------------------------- /src/RhythmString.tsx: -------------------------------------------------------------------------------- 1 | import { unzlibSync } from "fflate"; 2 | 3 | export type RhythmString = number[][]; 4 | 5 | export function parseRhythmString(rhythmString: string): RhythmString { 6 | rhythmString = rhythmString.replace(/-/g, "+").replace(/_/g, "/"); 7 | const compressed = new Uint8Array( 8 | atob(rhythmString) 9 | .split("") 10 | .map(c => c.charCodeAt(0)) 11 | ); 12 | const decompressed = unzlibSync(compressed); 13 | 14 | const input = new TextDecoder() 15 | .decode(decompressed) 16 | .split(" ") 17 | .map(s => parseInt(s)); 18 | const output: number[][] = []; 19 | if (input.length < 3) return output; 20 | 21 | const sampleRate = input.shift()!; 22 | const stepSize = input.shift()!; 23 | const stepDuration = stepSize / sampleRate; 24 | 25 | const channelCount = input.shift()!; 26 | if (input.length < channelCount) return output; 27 | 28 | for (let i = 0; i < channelCount; i++) { 29 | const channel: number[] = []; 30 | const entryCount = input.shift()!; 31 | if (input.length < entryCount + (channelCount - i - 1)) return output; 32 | 33 | for (let j = 0; j < entryCount; j++) { 34 | const entry = input.shift()! * stepDuration; 35 | channel.push(j == 0 ? entry : channel[j - 1] + entry); 36 | } 37 | 38 | output.push(channel); 39 | } 40 | 41 | return output; 42 | } 43 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useState } from "react"; 2 | import styles from "./css/app.module.scss"; 3 | import LoadingIcon from "./components/LoadingIcon"; 4 | import NCSVisualizer from "./components/renderer/NCSVisualizer"; 5 | import { CacheStatus, ExtensionKind, MetadataService } from "./metadata"; 6 | import { parseProtobuf } from "./protobuf/defs"; 7 | import { ColorResult } from "./protobuf/ColorResult"; 8 | import { ErrorData, ErrorHandlerContext, ErrorRecovery } from "./error"; 9 | import DebugVisualizer from "./components/renderer/DebugVisualizer"; 10 | import SpectrumVisualizer from "./components/renderer/SpectrumVisualizer"; 11 | import { MainMenuButton } from "./menu"; 12 | import { createVisualizerWindow } from "./window"; 13 | 14 | export type RendererProps = { 15 | isEnabled: boolean; 16 | themeColor: Spicetify.Color; 17 | audioAnalysis?: SpotifyAudioAnalysis; 18 | }; 19 | 20 | export type RendererDefinition = { 21 | id: string; 22 | name: string; 23 | renderer: React.FunctionComponent; 24 | }; 25 | 26 | const RENDERERS: RendererDefinition[] = [ 27 | { 28 | id: "ncs", 29 | name: "NCS", 30 | renderer: NCSVisualizer 31 | }, 32 | { 33 | id: "spectrum", 34 | name: "Spectrum (very WIP)", 35 | renderer: SpectrumVisualizer 36 | }, 37 | { 38 | id: "debug", 39 | name: "DEBUG", 40 | renderer: DebugVisualizer 41 | } 42 | ]; 43 | 44 | type VisualizerState = 45 | | { 46 | state: "loading" | "running"; 47 | } 48 | | { 49 | state: "error"; 50 | errorData: ErrorData; 51 | }; 52 | 53 | export default function App(props: { isSecondaryWindow?: boolean; initialRenderer?: string }) { 54 | const [rendererId, setRendererId] = useState(props.initialRenderer || "ncs"); 55 | const Renderer = RENDERERS.find(v => v.id === rendererId)?.renderer; 56 | 57 | const [state, setState] = useState({ state: "loading" }); 58 | const [trackData, setTrackData] = useState<{ audioAnalysis?: SpotifyAudioAnalysis; themeColor: Spicetify.Color }>({ 59 | themeColor: Spicetify.Color.fromHex("#535353") 60 | }); 61 | 62 | const updateState = useCallback( 63 | (newState: VisualizerState) => 64 | setState(oldState => { 65 | if (oldState.state === "error" && oldState.errorData.recovery === ErrorRecovery.NONE) return oldState; 66 | 67 | return newState; 68 | }), 69 | [] 70 | ); 71 | 72 | const onError = useCallback((msg: string, recovery: ErrorRecovery) => { 73 | updateState({ 74 | state: "error", 75 | errorData: { 76 | message: msg, 77 | recovery 78 | } 79 | }); 80 | }, []); 81 | 82 | const isUnrecoverableError = state.state === "error" && state.errorData.recovery === ErrorRecovery.NONE; 83 | 84 | const metadataService = useMemo(() => new MetadataService(), []); 85 | 86 | const updatePlayerState = useCallback( 87 | async (newState: Spicetify.PlayerState) => { 88 | const item = newState?.item; 89 | 90 | if (!item) { 91 | onError("Start playing a song to see the visualization!", ErrorRecovery.SONG_CHANGE); 92 | return; 93 | } 94 | 95 | const uri = Spicetify.URI.fromString(item.uri); 96 | if (uri.type !== Spicetify.URI.Type.TRACK) { 97 | onError("Error: The type of track you're listening to is currently not supported", ErrorRecovery.SONG_CHANGE); 98 | return; 99 | } 100 | 101 | updateState({ state: "loading" }); 102 | 103 | const analysisRequestUrl = `https://spclient.wg.spotify.com/audio-attributes/v1/audio-analysis/${uri.id}?format=json`; 104 | const [audioAnalysis, vibrantColor] = await Promise.all([ 105 | Spicetify.CosmosAsync.get(analysisRequestUrl).catch(e => console.error("[Visualizer]", e)) as Promise, 106 | metadataService 107 | .fetch(ExtensionKind.EXTRACTED_COLOR, item.metadata.image_url) 108 | .catch(s => console.error(`[Visualizer] Could not load extracted color metadata. Status: ${CacheStatus[s]}`)) 109 | .then(colors => { 110 | if ( 111 | !colors || 112 | colors.value.length === 0 || 113 | colors.typeUrl !== "type.googleapis.com/spotify.context_track_color.ColorResult" 114 | ) 115 | return Spicetify.Color.fromHex("#535353"); 116 | 117 | const colorResult = parseProtobuf(colors.value, ColorResult); 118 | const colorHex = colorResult.colorLight?.rgb?.toString(16).padStart(6, "0") ?? "535353"; 119 | return Spicetify.Color.fromHex(`#${colorHex}`); 120 | }) 121 | ]); 122 | 123 | if (!audioAnalysis) { 124 | onError( 125 | "Error: The audio analysis could not be loaded, please check your internet connection", 126 | ErrorRecovery.MANUAL 127 | ); 128 | return; 129 | } 130 | 131 | if (typeof audioAnalysis !== "object") { 132 | onError(`Invalid audio analysis data (${audioAnalysis})`, ErrorRecovery.MANUAL); 133 | return; 134 | } 135 | 136 | if (!("track" in audioAnalysis) || !("segments" in audioAnalysis)) { 137 | const message = 138 | "error" in audioAnalysis && audioAnalysis.error 139 | ? (audioAnalysis.error as string) 140 | : "message" in audioAnalysis && audioAnalysis.message 141 | ? (audioAnalysis.message as string) 142 | : "Unknown error"; 143 | 144 | const code = "code" in audioAnalysis ? (audioAnalysis.code as number) : null; 145 | 146 | if (code !== null) { 147 | onError(`Error ${code}: ${message}`, ErrorRecovery.MANUAL); 148 | return; 149 | } else { 150 | onError(message, ErrorRecovery.MANUAL); 151 | return; 152 | } 153 | } 154 | 155 | setTrackData({ audioAnalysis: audioAnalysis as SpotifyAudioAnalysis, themeColor: vibrantColor }); 156 | updateState({ state: "running" }); 157 | }, 158 | [metadataService] 159 | ); 160 | 161 | useEffect(() => { 162 | if (isUnrecoverableError) return; 163 | 164 | const songChangeListener = (event?: Event & { data: Spicetify.PlayerState }) => { 165 | if (event?.data) updatePlayerState(event.data); 166 | }; 167 | 168 | Spicetify.Player.addEventListener("songchange", songChangeListener); 169 | updatePlayerState(Spicetify.Player.data); 170 | 171 | return () => Spicetify.Player.removeEventListener("songchange", songChangeListener as PlayerEventListener); 172 | }, [isUnrecoverableError, updatePlayerState]); 173 | 174 | return ( 175 |
176 | {!isUnrecoverableError && ( 177 | <> 178 | 179 | {Renderer && ( 180 | 185 | )} 186 | 187 | {props.isSecondaryWindow || ( 188 | { 192 | if (!createVisualizerWindow(rendererId)) { 193 | Spicetify.showNotification("Failed to open a new window", true); 194 | } 195 | }} 196 | onSelectRenderer={id => setRendererId(id)} 197 | /> 198 | )} 199 | 200 | )} 201 | 202 | {state.state === "loading" ? ( 203 | 204 | ) : state.state === "error" ? ( 205 |
206 |
{state.errorData.message}
207 | {state.errorData.recovery === ErrorRecovery.MANUAL && ( 208 | updatePlayerState(Spicetify.Player.data)}> 209 | Try again 210 | 211 | )} 212 |
213 | ) : null} 214 |
215 | ); 216 | } 217 | -------------------------------------------------------------------------------- /src/components/AnimatedCanvas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useState, useCallback } from "react"; 2 | 3 | interface ContextTypeMap { 4 | "2d": CanvasRenderingContext2D; 5 | webgl: WebGLRenderingContext; 6 | webgl2: WebGL2RenderingContext; 7 | bitmaprenderer: ImageBitmapRenderingContext; 8 | } 9 | 10 | export default function AnimatedCanvas(props: { 11 | contextType: V; 12 | onInit: (ctx: ContextTypeMap[V] | null) => U; 13 | onResize: (ctx: ContextTypeMap[V] | null, state: U) => void; 14 | onRender: (ctx: ContextTypeMap[V] | null, data: T, state: U, time: number) => void; 15 | 16 | style?: React.CSSProperties; 17 | sizeConstraint?: (width: number, height: number) => { width: number; height: number }; 18 | 19 | data: T; 20 | isEnabled: boolean; 21 | }) { 22 | const { contextType, onInit, onResize, onRender, style, data, isEnabled } = props; 23 | const canvasRef = useRef(null); 24 | const [state, setState] = useState(null); 25 | 26 | const updateResolution = useCallback((canvas: HTMLCanvasElement, win: Window) => { 27 | const screenWidth = Math.round(canvas.clientWidth * window.devicePixelRatio); 28 | const screenHeight = Math.round(canvas.clientHeight * window.devicePixelRatio); 29 | 30 | const { width: newWidth, height: newHeight } = props.sizeConstraint?.(screenWidth, screenHeight) ?? { 31 | width: screenWidth, 32 | height: screenHeight 33 | }; 34 | 35 | if (canvas.width === newWidth && canvas.height === newHeight) return; 36 | canvas.width = newWidth; 37 | canvas.height = newHeight; 38 | }, []); 39 | 40 | useEffect(() => { 41 | if (!onInit) return; 42 | 43 | const canvas = canvasRef.current; 44 | if (!canvas) return; 45 | 46 | const win = canvas.ownerDocument.defaultView; 47 | if (!win) return; 48 | 49 | const context = canvas.getContext(contextType) as ContextTypeMap[V] | null; 50 | 51 | const state = onInit(context); 52 | updateResolution(canvas, win); 53 | onResize(context, state); 54 | setState(state); 55 | 56 | return () => setState(null); 57 | }, [contextType, onInit]); 58 | 59 | useEffect(() => { 60 | if (!isEnabled || !state || !onRender) return; 61 | 62 | const canvas = canvasRef.current; 63 | if (!canvas) return; 64 | 65 | const win = canvas.ownerDocument.defaultView; 66 | if (!win) return; 67 | 68 | const context = canvas.getContext(contextType) as ContextTypeMap[V] | null; 69 | 70 | let requestId = 0; 71 | const wrapper = (time: number) => { 72 | if (!state) return; 73 | 74 | onRender(context, data, state, time); 75 | requestId = win.requestAnimationFrame(wrapper); 76 | }; 77 | 78 | requestId = win.requestAnimationFrame(wrapper); 79 | return () => { 80 | if (requestId) win.cancelAnimationFrame(requestId); 81 | }; 82 | }, [contextType, onRender, data, state, isEnabled]); 83 | 84 | useEffect(() => { 85 | if (!canvasRef.current) return; 86 | 87 | const win = canvasRef.current.ownerDocument.defaultView; 88 | if (!win) return; 89 | 90 | const resizeObserver = new win.ResizeObserver(() => { 91 | const canvas = canvasRef.current; 92 | if (!canvas) return; 93 | 94 | const win = canvas.ownerDocument.defaultView; 95 | if (!win) return; 96 | 97 | updateResolution(canvas, win); 98 | 99 | const context = canvas.getContext(contextType) as ContextTypeMap[V] | null; 100 | if (context && state) onResize(context, state); 101 | }); 102 | 103 | resizeObserver.observe(canvasRef.current); 104 | return () => resizeObserver.disconnect(); 105 | }, [contextType, onResize, state]); 106 | 107 | return ( 108 | 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /src/components/LoadingIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function LoadingIcon() { 4 | return ( 5 | 6 | 7 | 17 | 27 | 28 | 29 | 39 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/renderer/DebugVisualizer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useMemo } from "react"; 2 | import AnimatedCanvas from "../AnimatedCanvas"; 3 | import { decibelsToAmplitude, binarySearchIndex, mapLinear } from "../../utils"; 4 | import { parseRhythmString, RhythmString } from "../../RhythmString"; 5 | import { ErrorHandlerContext, ErrorRecovery } from "../../error"; 6 | import { RendererProps } from "../../app"; 7 | 8 | type CanvasData = { 9 | barDuration: number; 10 | audioAnalysis?: SpotifyAudioAnalysis; 11 | rhythmString: RhythmString | null; 12 | }; 13 | 14 | type RendererState = 15 | | { 16 | isError: true; 17 | } 18 | | { 19 | isError: false; 20 | }; 21 | 22 | type Area = { x: number; y: number; width: number; height: number }; 23 | 24 | type Section = { 25 | name: string; 26 | render: ( 27 | ctx: CanvasRenderingContext2D, 28 | audio: { analysis: SpotifyAudioAnalysis; rhythm: RhythmString }, 29 | time: { start: number; end: number; current: number }, 30 | area: Area 31 | ) => void; 32 | } & ( 33 | | { 34 | layer: "background" | "overlay"; 35 | } 36 | | { 37 | layer: "content"; 38 | height: number; 39 | } 40 | ); 41 | 42 | const TIME_WINDOW = [1, 2]; 43 | 44 | const SECTION_SPACING = 20; 45 | const SECTION_TITLE_SIZE = 20; 46 | const SECTION_TITLE_SPACING = 10; 47 | const SECTIONS: Section[] = [ 48 | { 49 | name: "Beats", 50 | layer: "background", 51 | render: (ctx, audio, time, area) => { 52 | const start = binarySearchIndex(audio.analysis.beats, b => b.start, time.start); 53 | const end = binarySearchIndex(audio.analysis.beats, b => b.start, time.end); 54 | 55 | ctx.lineWidth = 1; 56 | ctx.strokeStyle = "#FFFFFF33"; 57 | ctx.beginPath(); 58 | for (let i = start; i <= end; i++) { 59 | const x = mapLinear(audio.analysis.beats[i].start, time.start, time.end, area.x, area.x + area.width); 60 | 61 | ctx.moveTo(x, area.y); 62 | ctx.lineTo(x, area.y + area.height); 63 | } 64 | ctx.stroke(); 65 | } 66 | }, 67 | { 68 | name: "Bars", 69 | layer: "background", 70 | render: (ctx, audio, time, area) => { 71 | const start = binarySearchIndex(audio.analysis.bars, b => b.start, time.start); 72 | const end = binarySearchIndex(audio.analysis.bars, b => b.start, time.end); 73 | 74 | ctx.lineWidth = 3; 75 | ctx.strokeStyle = "#FFFFFF66"; 76 | ctx.beginPath(); 77 | for (let i = start; i <= end; i++) { 78 | const x = mapLinear(audio.analysis.bars[i].start, time.start, time.end, area.x, area.x + area.width); 79 | 80 | ctx.moveTo(x, area.y); 81 | ctx.lineTo(x, area.y + area.height); 82 | } 83 | ctx.stroke(); 84 | } 85 | }, 86 | { 87 | name: "Position", 88 | layer: "overlay", 89 | render: (ctx, _audio, time, area) => { 90 | ctx.lineWidth = 5; 91 | ctx.strokeStyle = ctx.fillStyle = "white"; 92 | ctx.beginPath(); 93 | 94 | const x = mapLinear(time.current, time.start, time.end, area.x, area.x + area.width); 95 | 96 | ctx.moveTo(x, area.y); 97 | ctx.lineTo(x, area.y + area.height); 98 | ctx.stroke(); 99 | 100 | const triangleSize = 10; 101 | 102 | ctx.beginPath(); 103 | ctx.moveTo(x - triangleSize, area.y); 104 | ctx.lineTo(x + triangleSize, area.y); 105 | ctx.lineTo(x, area.y + triangleSize); 106 | ctx.lineTo(x - triangleSize, area.y); 107 | 108 | ctx.moveTo(x - triangleSize, area.y + area.height); 109 | ctx.lineTo(x + triangleSize, area.y + area.height); 110 | ctx.lineTo(x, area.y + area.height - triangleSize); 111 | ctx.lineTo(x - triangleSize, area.y + area.height); 112 | ctx.fill(); 113 | } 114 | }, 115 | { 116 | name: "Loudness", 117 | layer: "content", 118 | height: 1, 119 | render: (ctx, audio, time, area) => { 120 | const start = binarySearchIndex(audio.analysis.segments, b => b.start, time.start); 121 | const end = binarySearchIndex(audio.analysis.segments, b => b.start, time.end); 122 | 123 | const transformLoudness = (l: number) => decibelsToAmplitude(l); 124 | 125 | ctx.lineWidth = 2; 126 | ctx.strokeStyle = "white"; 127 | ctx.beginPath(); 128 | 129 | for (let i = start; i <= end + 1 && i < audio.analysis.segments.length; i++) { 130 | const segment = audio.analysis.segments[i]; 131 | 132 | const xStart = mapLinear(segment.start, time.start, time.end, area.x, area.x + area.width); 133 | const yStart = mapLinear(transformLoudness(segment.loudness_start), 0, 1, area.y + area.height, area.y); 134 | const xMax = mapLinear( 135 | segment.start + segment.loudness_max_time, 136 | time.start, 137 | time.end, 138 | area.x, 139 | area.x + area.width 140 | ); 141 | const yMax = mapLinear(transformLoudness(segment.loudness_max), 0, 1, area.y + area.height, area.y); 142 | 143 | if (i === start) { 144 | ctx.moveTo(xStart, yStart); 145 | } else { 146 | ctx.lineTo(xStart, yStart); 147 | } 148 | 149 | ctx.lineTo(xMax, yMax); 150 | 151 | if (i === audio.analysis.segments.length - 1) { 152 | const xEnd = mapLinear(segment.start + segment.duration, time.start, time.end, area.x, area.x + area.width); 153 | const yEnd = mapLinear(transformLoudness(segment.loudness_end), 0, 1, area.y + area.height, area.y); 154 | 155 | ctx.lineTo(xEnd, yEnd); 156 | } 157 | } 158 | 159 | ctx.stroke(); 160 | } 161 | }, 162 | { 163 | name: "Confidence", 164 | layer: "content", 165 | height: 0.25, 166 | render: (ctx, audio, time, area) => { 167 | const start = binarySearchIndex(audio.analysis.segments, b => b.start, time.start); 168 | const end = binarySearchIndex(audio.analysis.segments, b => b.start, time.end); 169 | 170 | ctx.beginPath(); 171 | 172 | for (let i = start; i <= end; i++) { 173 | const segment = audio.analysis.segments[i]; 174 | 175 | const xStart = mapLinear(segment.start, time.start, time.end, area.x, area.x + area.width); 176 | const xEnd = mapLinear(segment.start + segment.duration, time.start, time.end, area.x, area.x + area.width); 177 | 178 | ctx.fillStyle = `rgba(255, 255, 255, ${segment.confidence})`; 179 | ctx.fillRect(xStart, area.y, xEnd - xStart, area.height); 180 | } 181 | 182 | ctx.fill(); 183 | } 184 | }, 185 | { 186 | name: "Timbre", 187 | layer: "content", 188 | height: 1.5, 189 | render: (ctx, audio, time, area) => { 190 | const rowHeight = area.height / 12; 191 | 192 | const start = binarySearchIndex(audio.analysis.segments, b => b.start, time.start); 193 | const end = binarySearchIndex(audio.analysis.segments, b => b.start, time.end); 194 | 195 | for (let t = 0; t < 12; t++) { 196 | const goldenRatio = (Math.sqrt(5) - 1) / 2; 197 | const hue = t * goldenRatio; 198 | 199 | ctx.beginPath(); 200 | 201 | for (let i = start; i <= end; i++) { 202 | const segment = audio.analysis.segments[i]; 203 | const value = mapLinear(Math.tanh(0.02 * segment.timbre[t]), -1, 1, 0, 1); 204 | 205 | const xStart = mapLinear(segment.start, time.start, time.end, area.x, area.x + area.width); 206 | const xEnd = mapLinear(segment.start + segment.duration, time.start, time.end, area.x, area.x + area.width); 207 | 208 | const y = area.y + (t / 12) * area.height; 209 | 210 | ctx.fillStyle = `hsla(${hue * 360}, 100%, 70%, ${value})`; 211 | ctx.fillRect(xStart, y, xEnd - xStart, rowHeight); 212 | } 213 | 214 | ctx.fill(); 215 | } 216 | } 217 | }, 218 | { 219 | name: "Pitches", 220 | layer: "content", 221 | height: 1.5, 222 | render: (ctx, audio, time, area) => { 223 | const rowHeight = area.height / 12; 224 | 225 | const start = binarySearchIndex(audio.analysis.segments, b => b.start, time.start); 226 | const end = binarySearchIndex(audio.analysis.segments, b => b.start, time.end); 227 | 228 | for (let p = 0; p < 12; p++) { 229 | const hue = p / 12; 230 | 231 | ctx.beginPath(); 232 | 233 | for (let i = start; i <= end; i++) { 234 | const segment = audio.analysis.segments[i]; 235 | 236 | const xStart = mapLinear(segment.start, time.start, time.end, area.x, area.x + area.width); 237 | const xEnd = mapLinear(segment.start + segment.duration, time.start, time.end, area.x, area.x + area.width); 238 | 239 | const y = area.y + (p / 12) * area.height; 240 | 241 | ctx.fillStyle = `hsla(${hue * 360}, 100%, 70%, ${segment.pitches[p]})`; 242 | ctx.fillRect(xStart, y, xEnd - xStart, rowHeight); 243 | } 244 | 245 | ctx.fill(); 246 | } 247 | } 248 | }, 249 | { 250 | name: "Rhythm", 251 | layer: "content", 252 | height: 0.5, 253 | render: (ctx, audio, time, area) => { 254 | const markerHeight = area.height / audio.rhythm.length; 255 | const markerWidth = Math.min(markerHeight, 20); 256 | 257 | const timePad = (markerWidth / 2 / area.width) * (time.end - time.start); 258 | 259 | ctx.fillStyle = "white"; 260 | ctx.beginPath(); 261 | 262 | for (let c = audio.rhythm.length - 1; c >= 0; c--) { 263 | const start = binarySearchIndex(audio.rhythm[c], r => r, time.start - timePad); 264 | const end = binarySearchIndex(audio.rhythm[c], r => r, time.end + timePad); 265 | 266 | for (let i = start; i <= end; i++) { 267 | const x = mapLinear(audio.rhythm[c][i], time.start, time.end, area.x, area.x + area.width); 268 | const y = area.y + c * markerHeight; 269 | 270 | ctx.rect(x - markerWidth / 2, y, markerWidth, markerHeight); 271 | } 272 | } 273 | 274 | ctx.fill(); 275 | } 276 | } 277 | ]; 278 | 279 | export default function DebugVisualizer(props: RendererProps) { 280 | const onError = useContext(ErrorHandlerContext); 281 | 282 | const barDuration = useMemo(() => { 283 | if (!props.audioAnalysis) return 1; 284 | return props.audioAnalysis.bars.reduce((acc, val) => acc + val.duration, 0) / props.audioAnalysis.bars.length; 285 | }, [props.audioAnalysis]); 286 | 287 | const rhythmString = useMemo(() => { 288 | if (!props.audioAnalysis) return null; 289 | return parseRhythmString(props.audioAnalysis.track.rhythmstring); 290 | }, [props.audioAnalysis]); 291 | 292 | const onInit = useCallback((ctx: CanvasRenderingContext2D | null): RendererState => { 293 | if (!ctx) { 294 | onError("Error: 2D rendering is not supported", ErrorRecovery.NONE); 295 | return { isError: true }; 296 | } 297 | 298 | return { 299 | isError: false 300 | }; 301 | }, []); 302 | 303 | const onResize = useCallback((ctx: CanvasRenderingContext2D | null, state: RendererState) => { 304 | if (state.isError || !ctx) return; 305 | }, []); 306 | 307 | const onRender = useCallback((ctx: CanvasRenderingContext2D | null, data: CanvasData, state: RendererState) => { 308 | if (state.isError || !ctx || !data.audioAnalysis || !data.rhythmString) return; 309 | 310 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 311 | 312 | const progress = Spicetify.Player.getProgress() / 1000; 313 | const windowStart = progress - TIME_WINDOW[0] * data.barDuration; 314 | const windowEnd = progress + TIME_WINDOW[1] * data.barDuration; 315 | const time = { 316 | start: windowStart, 317 | end: windowEnd, 318 | current: progress 319 | }; 320 | 321 | const renderTitle = (title: string, y: number, height: number) => { 322 | ctx.save(); 323 | 324 | ctx.font = `${SECTION_TITLE_SIZE}px sans-serif`; 325 | ctx.textAlign = "center"; 326 | ctx.fillStyle = "white"; 327 | 328 | ctx.rotate((Math.PI * 3) / 2); 329 | ctx.fillText(title, -(y + height / 2), SECTION_TITLE_SIZE, height); 330 | 331 | ctx.restore(); 332 | }; 333 | 334 | const renderSection = (section: Section, area: Area) => { 335 | ctx.save(); 336 | 337 | ctx.beginPath(); 338 | ctx.rect(area.x, area.y, area.width, area.height); 339 | ctx.clip(); 340 | 341 | section.render(ctx, { analysis: data.audioAnalysis!, rhythm: data.rhythmString! }, time, area); 342 | 343 | ctx.restore(); 344 | }; 345 | 346 | for (const section of SECTIONS) { 347 | if (section.layer !== "background") continue; 348 | 349 | renderSection(section, { 350 | x: SECTION_TITLE_SIZE + SECTION_TITLE_SPACING, 351 | y: 0, 352 | width: ctx.canvas.width - SECTION_TITLE_SIZE - SECTION_TITLE_SPACING, 353 | height: ctx.canvas.height 354 | }); 355 | } 356 | 357 | const contentSections = SECTIONS.filter(s => s.layer === "content"); 358 | 359 | const contentSpace = ctx.canvas.height - SECTION_SPACING * (contentSections.length - 1); 360 | const totalHeight = contentSections.reduce((acc, val) => acc + val.height, 0); 361 | 362 | let currentHeight = 0; 363 | let currentCount = 0; 364 | 365 | for (const section of contentSections) { 366 | const yStart = mapLinear(currentHeight, 0, totalHeight, 0, contentSpace) + currentCount * SECTION_SPACING; 367 | const yEnd = 368 | mapLinear(currentHeight + section.height, 0, totalHeight, 0, contentSpace) + currentCount * SECTION_SPACING; 369 | 370 | currentHeight += section.height; 371 | currentCount++; 372 | 373 | renderSection(section, { 374 | x: SECTION_TITLE_SIZE + SECTION_TITLE_SPACING, 375 | y: yStart, 376 | width: ctx.canvas.width - SECTION_TITLE_SIZE - SECTION_TITLE_SPACING, 377 | height: yEnd - yStart 378 | }); 379 | 380 | renderTitle(section.name, yStart, yEnd - yStart); 381 | 382 | if (currentCount < contentSections.length) { 383 | ctx.lineWidth = 1; 384 | ctx.strokeStyle = "#AAAAAA"; 385 | 386 | ctx.beginPath(); 387 | ctx.moveTo(SECTION_SPACING / 2, yEnd + SECTION_SPACING / 2); 388 | ctx.lineTo(ctx.canvas.width - SECTION_SPACING / 2, yEnd + SECTION_SPACING / 2); 389 | ctx.stroke(); 390 | } 391 | } 392 | 393 | for (const section of SECTIONS) { 394 | if (section.layer !== "overlay") continue; 395 | 396 | renderSection(section, { 397 | x: SECTION_TITLE_SIZE + SECTION_TITLE_SPACING, 398 | y: 0, 399 | width: ctx.canvas.width - SECTION_TITLE_SIZE - SECTION_TITLE_SPACING, 400 | height: ctx.canvas.height 401 | }); 402 | } 403 | }, []); 404 | 405 | return ( 406 | 418 | ); 419 | } 420 | -------------------------------------------------------------------------------- /src/components/renderer/NCSVisualizer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useMemo } from "react"; 2 | import AnimatedCanvas from "../AnimatedCanvas"; 3 | import { 4 | sampleAmplitudeMovingAverage, 5 | decibelsToAmplitude, 6 | mapLinear, 7 | integrateLinearSegment, 8 | sampleAccumulatedIntegral 9 | } from "../../utils"; 10 | import { 11 | vertexShader as PARTICLE_VERT_SHADER, 12 | fragmentShader as PARTICLE_FRAG_SHADER 13 | } from "../../shaders/ncs-visualizer/particle"; 14 | import { vertexShader as DOT_VERT_SHADER, fragmentShader as DOT_FRAG_SHADER } from "../../shaders/ncs-visualizer/dot"; 15 | import { vertexShader as BLUR_VERT_SHADER, fragmentShader as BLUR_FRAG_SHADER } from "../../shaders/ncs-visualizer/blur"; 16 | import { 17 | vertexShader as FINALIZE_VERT_SHADER, 18 | fragmentShader as FINALIZE_FRAG_SHADER 19 | } from "../../shaders/ncs-visualizer/finalize"; 20 | import { ErrorHandlerContext, ErrorRecovery } from "../../error"; 21 | import { RendererProps } from "../../app"; 22 | 23 | type CanvasData = { 24 | themeColor: Spicetify.Color; 25 | seed: number; 26 | amplitudeCurve: CurveEntry[]; 27 | }; 28 | 29 | type RendererState = 30 | | { 31 | isError: true; 32 | } 33 | | { 34 | isError: false; 35 | particleShader: WebGLProgram; 36 | dotShader: WebGLProgram; 37 | blurShader: WebGLProgram; 38 | finalizeShader: WebGLProgram; 39 | viewportSize: number; 40 | particleTextureSize: number; 41 | 42 | inPositionLoc: number; 43 | inPositionLocDot: number; 44 | inPositionLocBlur: number; 45 | inPositionLocFinalize: number; 46 | 47 | uNoiseOffsetLoc: WebGLUniformLocation; 48 | uAmplitudeLoc: WebGLUniformLocation; 49 | uSeedLoc: WebGLUniformLocation; 50 | uDotSpacingLoc: WebGLUniformLocation; 51 | uDotOffsetLoc: WebGLUniformLocation; 52 | uSphereRadiusLoc: WebGLUniformLocation; 53 | uFeatherLoc: WebGLUniformLocation; 54 | uNoiseFrequencyLoc: WebGLUniformLocation; 55 | uNoiseAmplitudeLoc: WebGLUniformLocation; 56 | 57 | uDotCountLoc: WebGLUniformLocation; 58 | uDotRadiusLoc: WebGLUniformLocation; 59 | uDotRadiusPXLoc: WebGLUniformLocation; 60 | uParticleTextureLoc: WebGLUniformLocation; 61 | 62 | uBlurRadiusLoc: WebGLUniformLocation; 63 | uBlurDirectionLoc: WebGLUniformLocation; 64 | uBlurInputTextureLoc: WebGLUniformLocation; 65 | 66 | uOutputColorLoc: WebGLUniformLocation; 67 | uBlurredTextureLoc: WebGLUniformLocation; 68 | uOriginalTextureLoc: WebGLUniformLocation; 69 | 70 | quadBuffer: WebGLBuffer; 71 | 72 | particleFramebuffer: WebGLFramebuffer; 73 | particleTexture: WebGLTexture; 74 | dotFramebuffer: WebGLFramebuffer; 75 | dotTexture: WebGLTexture; 76 | blurXFramebuffer: WebGLFramebuffer; 77 | blurXTexture: WebGLTexture; 78 | blurYFramebuffer: WebGLFramebuffer; 79 | blurYTexture: WebGLTexture; 80 | }; 81 | 82 | export default function NCSVisualizer(props: RendererProps) { 83 | const onError = useContext(ErrorHandlerContext); 84 | 85 | const amplitudeCurve = useMemo(() => { 86 | if (!props.audioAnalysis) return [{ x: 0, y: 0 }]; 87 | 88 | const segments = props.audioAnalysis.segments; 89 | 90 | const amplitudeCurve: CurveEntry[] = segments.flatMap(segment => 91 | segment.loudness_max_time 92 | ? [ 93 | { x: segment.start, y: decibelsToAmplitude(segment.loudness_start) }, 94 | { x: segment.start + segment.loudness_max_time, y: decibelsToAmplitude(segment.loudness_max) } 95 | ] 96 | : [{ x: segment.start, y: decibelsToAmplitude(segment.loudness_start) }] 97 | ); 98 | 99 | if (segments.length) { 100 | amplitudeCurve[0].accumulatedIntegral = 0; 101 | for (let i = 1; i < amplitudeCurve.length; i++) { 102 | const prev = amplitudeCurve[i - 1]; 103 | const curr = amplitudeCurve[i]; 104 | curr.accumulatedIntegral = (prev.accumulatedIntegral ?? 0) + integrateLinearSegment(prev, curr); 105 | } 106 | 107 | const lastSegment = segments[segments.length - 1]; 108 | amplitudeCurve.push({ 109 | x: lastSegment.start + lastSegment.duration, 110 | y: decibelsToAmplitude(lastSegment.loudness_end) 111 | }); 112 | } 113 | 114 | return amplitudeCurve; 115 | }, [props.audioAnalysis]); 116 | 117 | const seed = props.audioAnalysis?.meta.timestamp ?? 0; 118 | 119 | const onInit = useCallback((gl: WebGL2RenderingContext | null): RendererState => { 120 | if (!gl) { 121 | onError("Error: WebGL2 is not supported", ErrorRecovery.NONE); 122 | return { isError: true }; 123 | } 124 | 125 | if (!gl.getExtension("EXT_color_buffer_float")) { 126 | onError(`Error: Rendering to floating-point textures is not supported`, ErrorRecovery.NONE); 127 | return { isError: true }; 128 | } 129 | 130 | const createShader = (type: number, source: string, name: string) => { 131 | const shader = gl.createShader(type)!; 132 | gl.shaderSource(shader, source); 133 | gl.compileShader(shader); 134 | 135 | if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS) && !gl.isContextLost()) { 136 | const msg = `Error: Failed to compile '${name}' shader`; 137 | const log = gl.getShaderInfoLog(shader); 138 | console.error(`[Visualizer] ${msg}`, log); 139 | 140 | onError(msg, ErrorRecovery.NONE); 141 | return null; 142 | } 143 | 144 | return shader; 145 | }; 146 | 147 | const createProgram = (vertShader: WebGLShader, fragShader: WebGLShader, name: string) => { 148 | const shader = gl.createProgram()!; 149 | gl.attachShader(shader, vertShader); 150 | gl.attachShader(shader, fragShader); 151 | gl.linkProgram(shader); 152 | 153 | if (!gl.getProgramParameter(shader, gl.LINK_STATUS) && !gl.isContextLost()) { 154 | const msg = `Error: Failed to link '${name}' shader`; 155 | const log = gl.getProgramInfoLog(shader); 156 | console.error(`[Visualizer] ${msg}`, log); 157 | 158 | onError(msg, ErrorRecovery.NONE); 159 | return null; 160 | } 161 | 162 | return shader; 163 | }; 164 | 165 | const createFramebuffer = (filter: number) => { 166 | const framebuffer = gl.createFramebuffer()!; 167 | gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); 168 | 169 | const texture = gl.createTexture()!; 170 | gl.bindTexture(gl.TEXTURE_2D, texture); 171 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 172 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); 173 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); 174 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); 175 | 176 | gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); 177 | 178 | return { framebuffer, texture }; 179 | }; 180 | 181 | const particleVertShader = createShader(gl.VERTEX_SHADER, PARTICLE_VERT_SHADER, "particle vertex"); 182 | if (!particleVertShader) return { isError: true }; 183 | const particleFragShader = createShader(gl.FRAGMENT_SHADER, PARTICLE_FRAG_SHADER, "particle fragment"); 184 | if (!particleFragShader) return { isError: true }; 185 | const particleShader = createProgram(particleVertShader, particleFragShader, "particle"); 186 | if (!particleShader) return { isError: true }; 187 | 188 | const inPositionLoc = gl.getAttribLocation(particleShader, "inPosition")!; 189 | const uNoiseOffsetLoc = gl.getUniformLocation(particleShader, "uNoiseOffset")!; 190 | const uAmplitudeLoc = gl.getUniformLocation(particleShader, "uAmplitude")!; 191 | const uSeedLoc = gl.getUniformLocation(particleShader, "uSeed")!; 192 | const uDotSpacingLoc = gl.getUniformLocation(particleShader, "uDotSpacing")!; 193 | const uDotOffsetLoc = gl.getUniformLocation(particleShader, "uDotOffset")!; 194 | const uSphereRadiusLoc = gl.getUniformLocation(particleShader, "uSphereRadius")!; 195 | const uFeatherLoc = gl.getUniformLocation(particleShader, "uFeather")!; 196 | const uNoiseFrequencyLoc = gl.getUniformLocation(particleShader, "uNoiseFrequency")!; 197 | const uNoiseAmplitudeLoc = gl.getUniformLocation(particleShader, "uNoiseAmplitude")!; 198 | 199 | const dotVertShader = createShader(gl.VERTEX_SHADER, DOT_VERT_SHADER, "dot vertex"); 200 | if (!dotVertShader) return { isError: true }; 201 | const dotFragShader = createShader(gl.FRAGMENT_SHADER, DOT_FRAG_SHADER, "dot fragment"); 202 | if (!dotFragShader) return { isError: true }; 203 | const dotShader = createProgram(dotVertShader, dotFragShader, "dot"); 204 | if (!dotShader) return { isError: true }; 205 | 206 | const inPositionLocDot = gl.getAttribLocation(dotShader, "inPosition")!; 207 | const uDotCountLoc = gl.getUniformLocation(dotShader, "uDotCount")!; 208 | const uDotRadiusLoc = gl.getUniformLocation(dotShader, "uDotRadius")!; 209 | const uDotRadiusPXLoc = gl.getUniformLocation(dotShader, "uDotRadiusPX")!; 210 | const uParticleTextureLoc = gl.getUniformLocation(dotShader, "uParticleTexture")!; 211 | 212 | const blurVertShader = createShader(gl.VERTEX_SHADER, BLUR_VERT_SHADER, "blur vertex"); 213 | if (!blurVertShader) return { isError: true }; 214 | const blurFragShader = createShader(gl.FRAGMENT_SHADER, BLUR_FRAG_SHADER, "blur fragment"); 215 | if (!blurFragShader) return { isError: true }; 216 | const blurShader = createProgram(blurVertShader, blurFragShader, "blur"); 217 | if (!blurShader) return { isError: true }; 218 | 219 | const inPositionLocBlur = gl.getAttribLocation(blurShader, "inPosition")!; 220 | const uBlurRadiusLoc = gl.getUniformLocation(blurShader, "uBlurRadius")!; 221 | const uBlurDirectionLoc = gl.getUniformLocation(blurShader, "uBlurDirection")!; 222 | const uBlurInputTextureLoc = gl.getUniformLocation(blurShader, "uInputTexture")!; 223 | 224 | const finalizeVertShader = createShader(gl.VERTEX_SHADER, FINALIZE_VERT_SHADER, "finalize vertex"); 225 | if (!finalizeVertShader) return { isError: true }; 226 | const finalizeFragShader = createShader(gl.FRAGMENT_SHADER, FINALIZE_FRAG_SHADER, "finalize fragment"); 227 | if (!finalizeFragShader) return { isError: true }; 228 | const finalizeShader = createProgram(finalizeVertShader, finalizeFragShader, "finalize"); 229 | if (!finalizeShader) return { isError: true }; 230 | 231 | const inPositionLocFinalize = gl.getAttribLocation(finalizeShader, "inPosition")!; 232 | const uOutputColorLoc = gl.getUniformLocation(finalizeShader, "uOutputColor")!; 233 | const uBlurredTextureLoc = gl.getUniformLocation(finalizeShader, "uBlurredTexture")!; 234 | const uOriginalTextureLoc = gl.getUniformLocation(finalizeShader, "uOriginalTexture")!; 235 | 236 | const { framebuffer: particleFramebuffer, texture: particleTexture } = createFramebuffer(gl.NEAREST); 237 | const { framebuffer: dotFramebuffer, texture: dotTexture } = createFramebuffer(gl.NEAREST); 238 | const { framebuffer: blurXFramebuffer, texture: blurXTexture } = createFramebuffer(gl.LINEAR); 239 | const { framebuffer: blurYFramebuffer, texture: blurYTexture } = createFramebuffer(gl.NEAREST); 240 | 241 | const quadBuffer = gl.createBuffer()!; 242 | gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); 243 | // prettier-ignore 244 | gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ 245 | -1, -1, 246 | -1, 1, 247 | 1, 1, 248 | 1, -1 249 | ]), gl.STATIC_DRAW); 250 | 251 | gl.enable(gl.BLEND); 252 | gl.blendEquation(gl.MAX); 253 | 254 | return { 255 | isError: false, 256 | particleShader, 257 | dotShader, 258 | blurShader, 259 | finalizeShader, 260 | viewportSize: 0, 261 | particleTextureSize: 0, 262 | 263 | inPositionLoc, 264 | inPositionLocDot, 265 | inPositionLocBlur, 266 | inPositionLocFinalize, 267 | 268 | uNoiseOffsetLoc, 269 | uAmplitudeLoc, 270 | uSeedLoc, 271 | uDotSpacingLoc, 272 | uDotOffsetLoc, 273 | uSphereRadiusLoc, 274 | uFeatherLoc, 275 | uNoiseFrequencyLoc, 276 | uNoiseAmplitudeLoc, 277 | 278 | uDotCountLoc, 279 | uDotRadiusLoc, 280 | uDotRadiusPXLoc, 281 | uParticleTextureLoc, 282 | 283 | uBlurRadiusLoc, 284 | uBlurDirectionLoc, 285 | uBlurInputTextureLoc, 286 | 287 | uOutputColorLoc, 288 | uBlurredTextureLoc, 289 | uOriginalTextureLoc, 290 | 291 | quadBuffer, 292 | 293 | particleFramebuffer, 294 | particleTexture, 295 | dotFramebuffer, 296 | dotTexture, 297 | blurXFramebuffer, 298 | blurXTexture, 299 | blurYFramebuffer, 300 | blurYTexture 301 | }; 302 | }, []); 303 | 304 | const onResize = useCallback((gl: WebGL2RenderingContext | null, state: RendererState) => { 305 | if (state.isError || !gl) return; 306 | 307 | state.viewportSize = Math.min(gl.canvas.width, gl.canvas.height); 308 | gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); 309 | 310 | gl.bindTexture(gl.TEXTURE_2D, state.dotTexture); 311 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, state.viewportSize, state.viewportSize, 0, gl.RED, gl.UNSIGNED_BYTE, null); 312 | 313 | gl.bindTexture(gl.TEXTURE_2D, state.blurXTexture); 314 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, state.viewportSize, state.viewportSize, 0, gl.RED, gl.UNSIGNED_BYTE, null); 315 | 316 | gl.bindTexture(gl.TEXTURE_2D, state.blurYTexture); 317 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, state.viewportSize, state.viewportSize, 0, gl.RED, gl.UNSIGNED_BYTE, null); 318 | }, []); 319 | 320 | const onRender = useCallback((gl: WebGL2RenderingContext | null, data: CanvasData, state: RendererState) => { 321 | if (state.isError || !gl) return; 322 | 323 | const progress = Spicetify.Player.getProgress() / 1000; 324 | 325 | const uNoiseOffset = (0.5 * progress + sampleAccumulatedIntegral(data.amplitudeCurve, progress)) * 75 * 0.01; 326 | const uAmplitude = sampleAmplitudeMovingAverage(data.amplitudeCurve, progress, 0.15); 327 | const uSeed = data.seed; 328 | const uDotCount = 322; 329 | const uDotRadius = 0.9 / uDotCount; 330 | const uDotRadiusPX = uDotRadius * 0.5 * state.viewportSize; 331 | const uDotSpacing = 0.9; 332 | const uDotOffset = -0.9 / 2; 333 | const uSphereRadius = mapLinear(uAmplitude, 0, 1, 0.75 * 0.9, 0.9); 334 | const uFeather = Math.pow(uAmplitude + 3, 2) * (45 / 1568); 335 | const uNoiseFrequency = 4; 336 | const uNoiseAmplitude = 0.32 * 0.9; 337 | 338 | if (state.particleTextureSize !== uDotCount) { 339 | state.particleTextureSize = uDotCount; 340 | 341 | gl.bindTexture(gl.TEXTURE_2D, state.particleTexture); 342 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG32F, uDotCount, uDotCount, 0, gl.RG, gl.FLOAT, null); 343 | } 344 | 345 | // calculate particle positions 346 | gl.disable(gl.BLEND); 347 | gl.bindFramebuffer(gl.FRAMEBUFFER, state.particleFramebuffer); 348 | gl.viewport(0, 0, uDotCount, uDotCount); 349 | 350 | gl.clearColor(0, 0, 0, 0); 351 | gl.clear(gl.COLOR_BUFFER_BIT); 352 | 353 | gl.useProgram(state.particleShader); 354 | gl.uniform1f(state.uNoiseOffsetLoc, uNoiseOffset); 355 | gl.uniform1f(state.uAmplitudeLoc, uAmplitude); 356 | gl.uniform1i(state.uSeedLoc, uSeed); 357 | gl.uniform1f(state.uDotSpacingLoc, uDotSpacing); 358 | gl.uniform1f(state.uDotOffsetLoc, uDotOffset); 359 | gl.uniform1f(state.uSphereRadiusLoc, uSphereRadius); 360 | gl.uniform1f(state.uFeatherLoc, uFeather); 361 | gl.uniform1f(state.uNoiseFrequencyLoc, uNoiseFrequency); 362 | gl.uniform1f(state.uNoiseAmplitudeLoc, uNoiseAmplitude); 363 | 364 | gl.bindBuffer(gl.ARRAY_BUFFER, state.quadBuffer); 365 | gl.enableVertexAttribArray(state.inPositionLoc); 366 | gl.vertexAttribPointer(state.inPositionLoc, 2, gl.FLOAT, false, 0, 0); 367 | gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); 368 | 369 | // render dots 370 | gl.enable(gl.BLEND); 371 | gl.bindFramebuffer(gl.FRAMEBUFFER, state.dotFramebuffer); 372 | gl.viewport(0, 0, state.viewportSize, state.viewportSize); 373 | 374 | gl.clearColor(0, 0, 0, 0); 375 | gl.clear(gl.COLOR_BUFFER_BIT); 376 | 377 | gl.useProgram(state.dotShader); 378 | gl.uniform1i(state.uDotCountLoc, uDotCount); 379 | gl.uniform1f(state.uDotRadiusLoc, uDotRadius); 380 | gl.uniform1f(state.uDotRadiusPXLoc, uDotRadiusPX); 381 | gl.uniform1i(state.uParticleTextureLoc, 0); 382 | 383 | gl.activeTexture(gl.TEXTURE0); 384 | gl.bindTexture(gl.TEXTURE_2D, state.particleTexture); 385 | 386 | gl.bindBuffer(gl.ARRAY_BUFFER, state.quadBuffer); 387 | gl.enableVertexAttribArray(state.inPositionLocDot); 388 | gl.vertexAttribPointer(state.inPositionLocDot, 2, gl.FLOAT, false, 0, 0); 389 | 390 | gl.drawArraysInstanced(gl.TRIANGLE_FAN, 0, 4, uDotCount * uDotCount); 391 | 392 | // blur in X direction 393 | gl.bindFramebuffer(gl.FRAMEBUFFER, state.blurXFramebuffer); 394 | gl.clearColor(0, 0, 0, 0); 395 | gl.clear(gl.COLOR_BUFFER_BIT); 396 | 397 | gl.useProgram(state.blurShader); 398 | gl.uniform1f(state.uBlurRadiusLoc, 0.01 * state.viewportSize); 399 | gl.uniform2f(state.uBlurDirectionLoc, 1 / state.viewportSize, 0); 400 | gl.uniform1i(state.uBlurInputTextureLoc, 0); 401 | 402 | gl.activeTexture(gl.TEXTURE0); 403 | gl.bindTexture(gl.TEXTURE_2D, state.dotTexture); 404 | 405 | gl.bindBuffer(gl.ARRAY_BUFFER, state.quadBuffer); 406 | gl.enableVertexAttribArray(state.inPositionLocBlur); 407 | gl.vertexAttribPointer(state.inPositionLocBlur, 2, gl.FLOAT, false, 0, 0); 408 | gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); 409 | 410 | // blur in Y direction 411 | gl.bindFramebuffer(gl.FRAMEBUFFER, state.blurYFramebuffer); 412 | gl.clearColor(0, 0, 0, 0); 413 | gl.clear(gl.COLOR_BUFFER_BIT); 414 | 415 | gl.uniform2f(state.uBlurDirectionLoc, 0, 1 / state.viewportSize); 416 | gl.bindTexture(gl.TEXTURE_2D, state.blurXTexture); 417 | gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); 418 | 419 | gl.bindFramebuffer(gl.FRAMEBUFFER, null); 420 | gl.clearColor(0, 0, 0, 0); 421 | gl.clear(gl.COLOR_BUFFER_BIT); 422 | 423 | // combine blurred and original 424 | gl.useProgram(state.finalizeShader); 425 | gl.uniform3f( 426 | state.uOutputColorLoc, 427 | data.themeColor.rgb.r / 255, 428 | data.themeColor.rgb.g / 255, 429 | data.themeColor.rgb.b / 255 430 | ); 431 | gl.uniform1i(state.uBlurredTextureLoc, 0); 432 | gl.uniform1i(state.uOriginalTextureLoc, 1); 433 | 434 | gl.activeTexture(gl.TEXTURE0); 435 | gl.bindTexture(gl.TEXTURE_2D, state.blurYTexture); 436 | gl.activeTexture(gl.TEXTURE1); 437 | gl.bindTexture(gl.TEXTURE_2D, state.dotTexture); 438 | 439 | gl.bindBuffer(gl.ARRAY_BUFFER, state.quadBuffer); 440 | gl.enableVertexAttribArray(state.inPositionLocFinalize); 441 | gl.vertexAttribPointer(state.inPositionLocFinalize, 2, gl.FLOAT, false, 0, 0); 442 | gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); 443 | }, []); 444 | 445 | return ( 446 | { 459 | const size = Math.min(width, height); 460 | return { width: size, height: size }; 461 | }} 462 | /> 463 | ); 464 | } 465 | -------------------------------------------------------------------------------- /src/components/renderer/SpectrumVisualizer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useMemo } from "react"; 2 | import AnimatedCanvas from "../AnimatedCanvas"; 3 | import { decibelsToAmplitude, binarySearchIndex, sampleSegmentedFunction, smoothstep, mapLinear } from "../../utils"; 4 | import { parseRhythmString } from "../../RhythmString"; 5 | import { ErrorHandlerContext, ErrorRecovery } from "../../error"; 6 | import { RendererProps } from "../../app"; 7 | 8 | type CanvasData = { 9 | themeColor: Spicetify.Color; 10 | spectrumData: { x: number; y: number }[][]; 11 | }; 12 | 13 | type RendererState = 14 | | { 15 | isError: true; 16 | } 17 | | { 18 | isError: false; 19 | }; 20 | 21 | export default function SpectrumVisualizer(props: RendererProps) { 22 | const onError = useContext(ErrorHandlerContext); 23 | 24 | const spectrumData = useMemo(() => { 25 | if (!props.audioAnalysis) return []; 26 | 27 | if (props.audioAnalysis.track.rhythm_version !== 1) { 28 | onError( 29 | `Error: Unsupported rhythmstring version ${props.audioAnalysis.track.rhythm_version}`, 30 | ErrorRecovery.SONG_CHANGE 31 | ); 32 | return []; 33 | } 34 | 35 | const segments = props.audioAnalysis.segments; 36 | const rhythm = parseRhythmString(props.audioAnalysis.track.rhythmstring); 37 | 38 | if (segments.length === 0 || rhythm.length === 0) return []; 39 | 40 | const RHYTHM_WEIGHT = 0.4; 41 | const RHYTHM_OFFSET = 0.2; 42 | const FALLOFF_SPEED = 0.4; 43 | 44 | const rhythmWindowSize = (RHYTHM_WEIGHT / Math.sqrt(2)) * 8; 45 | 46 | const channelCount = 12 * rhythm.length; 47 | const channelSegments: number[][] = []; 48 | 49 | for (let i = 0; i < segments.length; i++) { 50 | const segment = segments[i]; 51 | const amplitudeStart = decibelsToAmplitude(segment.loudness_start); 52 | const amplitudeMax = decibelsToAmplitude(segment.loudness_max); 53 | const peakPosition = segment.start + segment.loudness_max_time; 54 | const pitches = segment.pitches; 55 | 56 | const rhythmWindowStart = peakPosition - rhythmWindowSize; 57 | const rhythmWindowEnd = peakPosition + rhythmWindowSize; 58 | const frequencies = rhythm.map(channel => { 59 | const start = binarySearchIndex(channel, e => e, rhythmWindowStart); 60 | const end = binarySearchIndex(channel, e => e, rhythmWindowEnd); 61 | 62 | return ( 63 | channel 64 | .slice(start, end) 65 | .map(e => Math.exp(-Math.pow((e - peakPosition) / RHYTHM_WEIGHT, 2))) 66 | .reduce((a, b) => a + b, 0) + RHYTHM_OFFSET 67 | ); 68 | }); 69 | 70 | const frequenciesMax = Math.max(...frequencies); 71 | for (let i = 0; i < frequencies.length; i++) frequencies[i] /= frequenciesMax; 72 | 73 | const channels: number[] = Array(channelCount); 74 | for (let j = 0; j < frequencies.length; j++) { 75 | const pitchVariation = mapLinear(j, 0, frequencies.length - 1, 0.2, 0.6); 76 | 77 | for (let k = 0; k < 12; k++) { 78 | const frequency = sampleSegmentedFunction( 79 | [...frequencies.entries()], 80 | e => e[0], 81 | e => e[1], 82 | smoothstep, 83 | j + k / 12 84 | ); 85 | const pitchAvg = pitches.reduce((a, b) => a + b, 0) / pitches.length; 86 | const pitch = pitches[k] * pitchVariation + pitchAvg * (1 - pitchVariation); 87 | channels[12 * j + k] = frequency * pitch; 88 | } 89 | } 90 | 91 | channelSegments.push([segment.start, ...channels.map(c => c * amplitudeStart)]); 92 | channelSegments.push([peakPosition, ...channels.map(c => c * amplitudeMax)]); 93 | 94 | if (i == segments.length - 1) { 95 | const amplitudeEnd = decibelsToAmplitude(segment.loudness_end); 96 | channelSegments.push([segment.start + segment.duration, ...channels.map(c => c * amplitudeEnd)]); 97 | } 98 | } 99 | 100 | const spectrumData: { x: number; y: number }[][] = Array(channelCount) 101 | .fill(0) 102 | .map(_ => Array(channelSegments.length)); 103 | for (let i = 0; i < channelCount; i++) { 104 | let channelIndex = 0; 105 | let prevSegment = { x: 0, y: 0 }; 106 | let prevPeak = { x: 0, y: 0 }; 107 | 108 | for (let j = 0; j < channelSegments.length; j++) { 109 | const currentSegment = { x: channelSegments[j][0], y: channelSegments[j][i + 1] }; 110 | const currentEnd = currentSegment.x + currentSegment.y / FALLOFF_SPEED; 111 | const prevPeakEnd = prevPeak.x + prevPeak.y / FALLOFF_SPEED; 112 | 113 | if (currentEnd > prevPeakEnd) { 114 | if (prevPeak.x !== prevSegment.x) { 115 | const m1 = (currentSegment.y - prevSegment.y) / (currentSegment.x - prevSegment.x); 116 | const b1 = prevSegment.y - m1 * prevSegment.x; 117 | const m2 = -FALLOFF_SPEED; 118 | const b2 = prevPeak.y - m2 * prevPeak.x; 119 | 120 | const cx = (b2 - b1) / (m1 - m2); 121 | const cy = m1 * cx + b1; 122 | 123 | spectrumData[i][channelIndex] = { x: cx, y: cy }; 124 | channelIndex++; 125 | } 126 | 127 | prevPeak = currentSegment; 128 | 129 | spectrumData[i][channelIndex] = currentSegment; 130 | channelIndex++; 131 | } 132 | 133 | prevSegment = currentSegment; 134 | } 135 | 136 | spectrumData[i].length = channelIndex; 137 | } 138 | 139 | return spectrumData; 140 | }, [props.audioAnalysis]); 141 | 142 | const onInit = useCallback((ctx: CanvasRenderingContext2D | null): RendererState => { 143 | if (!ctx) { 144 | onError("Error: 2D rendering is not supported", ErrorRecovery.NONE); 145 | return { isError: true }; 146 | } 147 | 148 | return { 149 | isError: false 150 | }; 151 | }, []); 152 | 153 | const onResize = useCallback((ctx: CanvasRenderingContext2D | null, state: RendererState) => { 154 | if (state.isError || !ctx) return; 155 | }, []); 156 | 157 | const onRender = useCallback((ctx: CanvasRenderingContext2D | null, data: CanvasData, state: RendererState) => { 158 | if (state.isError || !ctx) return; 159 | 160 | const progress = Spicetify.Player.getProgress() / 1000; 161 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 162 | ctx.fillStyle = data.themeColor.toCSS(Spicetify.Color.CSSFormat.HEX); 163 | 164 | const barCount = data.spectrumData.length; 165 | const barWidth = (ctx.canvas.width / barCount) * 0.7; 166 | const spaceWidth = (ctx.canvas.width - barWidth * barCount) / (barCount + 1); 167 | 168 | for (let i = 0; i < barCount; i++) { 169 | const value = sampleSegmentedFunction( 170 | data.spectrumData[i], 171 | x => x.x, 172 | x => x.y, 173 | x => x, 174 | progress 175 | ); 176 | ctx.fillRect( 177 | spaceWidth * (i + 1) + barWidth * i, 178 | ctx.canvas.height - value * ctx.canvas.height, 179 | barWidth, 180 | value * ctx.canvas.height 181 | ); 182 | } 183 | }, []); 184 | 185 | return ( 186 | 198 | ); 199 | } 200 | -------------------------------------------------------------------------------- /src/css/app.module.scss: -------------------------------------------------------------------------------- 1 | :global(.visualizer-container) { 2 | // fill available space 3 | position: absolute; 4 | inset: 0; 5 | } 6 | 7 | :global(.visualizer-container) > *:not(.main_menu_button) { 8 | position: absolute; 9 | 10 | top: 50%; 11 | left: 50%; 12 | translate: -50% -50%; 13 | } 14 | 15 | .main_menu_button { 16 | position: absolute; 17 | top: 10px; 18 | right: 10px; 19 | } 20 | 21 | .error_container { 22 | max-width: 50%; 23 | 24 | display: flex; 25 | flex-direction: column; 26 | 27 | align-items: center; 28 | gap: 2rem; 29 | } 30 | 31 | .error_message { 32 | font-size: 24px; 33 | font-weight: 700; 34 | 35 | text-align: center; 36 | } 37 | -------------------------------------------------------------------------------- /src/css/icon-active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/css/icon-inactive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/css/ncs-active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/css/ncs-inactive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export type ErrorHandler = (msg: string, recovery: ErrorRecovery) => void; 4 | 5 | export const ErrorHandlerContext = createContext(() => {}); 6 | 7 | export enum ErrorRecovery { 8 | MANUAL, 9 | SONG_CHANGE, 10 | NONE 11 | } 12 | 13 | export type ErrorData = { 14 | message: string; 15 | recovery: ErrorRecovery; 16 | }; 17 | -------------------------------------------------------------------------------- /src/menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { RendererDefinition } from "./app"; 3 | 4 | const SpotifyIcon = React.memo((props: { name: Spicetify.Icon; size: number }) => ( 5 | 10 | )); 11 | 12 | type MainMenuProps = { renderers: RendererDefinition[]; onSelectRenderer: (id: string) => void; onOpenWindow: () => void }; 13 | 14 | const MainMenu = React.memo((props: MainMenuProps) => ( 15 | 16 | 17 | {props.renderers.map(v => ( 18 | props.onSelectRenderer(v.id)}> 19 | {v.name} 20 | 21 | ))} 22 | 23 | props.onOpenWindow()} 25 | trailingIcon={} 26 | > 27 | Open Window 28 | 29 | 30 | )); 31 | 32 | export const MainMenuButton = React.memo((props: MainMenuProps & { className: string }) => { 33 | return ( 34 | }> 35 | } 39 | > 40 | 41 | ); 42 | }); 43 | -------------------------------------------------------------------------------- /src/metadata.ts: -------------------------------------------------------------------------------- 1 | export enum ExtensionKind { 2 | UNKNOWN_EXTENSION = 0, 3 | EXTRACTED_COLOR = 23 // type.googleapis.com/spotify.context_track_color.ColorResult 4 | } 5 | 6 | export enum CacheStatus { 7 | UNKNOWN = 0, 8 | OK = 1, 9 | NOT_RESOLVED = 2, 10 | NOT_FOUND = 3, 11 | UNAVAILABLE_FOR_LEGAL_REASONS = 4 12 | } 13 | 14 | export class MetadataService { 15 | service: any; 16 | serviceDescriptor: any; 17 | 18 | public constructor() { 19 | const webpack = (window as any).webpackChunkclient_web ?? (window as any).webpackChunkopen; 20 | const require = webpack.push([[Symbol()], {}, (re: any) => re]); 21 | const cache = Object.keys(require.m).map(id => require(id)); 22 | const modules = cache 23 | .filter(module => typeof module === "object") 24 | .map(module => { 25 | try { 26 | return Object.values(module); 27 | } catch {} 28 | }) 29 | .flat(); 30 | 31 | const metadataService = modules.filter( 32 | m => 33 | m && 34 | typeof m === "function" && 35 | "SERVICE_ID" in m && 36 | m.SERVICE_ID === "spotify.mdata_esperanto.proto.MetadataService" 37 | ); 38 | const createTransport = modules.filter( 39 | m => 40 | m && 41 | typeof m === "function" && 42 | m.toString().includes("executeEsperantoCall") && 43 | m.toString().includes("cancelEsperantoCall") 44 | ); 45 | 46 | if (metadataService.length !== 1) return; 47 | if (createTransport.length !== 1) return; 48 | 49 | this.serviceDescriptor = metadataService[0] as any; 50 | this.service = new this.serviceDescriptor((createTransport[0] as any)()); 51 | } 52 | 53 | public fetch(kind: ExtensionKind, entityUri: string): Promise<{ typeUrl: string; value: Uint8Array }> { 54 | return new Promise((resolve, reject) => { 55 | const cancel = this.service.observe( 56 | this.serviceDescriptor.METHODS.observe.requestType.fromPartial({ 57 | extensionQuery: [ 58 | { 59 | entityUri: entityUri, 60 | extensionKind: kind 61 | } 62 | ] 63 | }), 64 | (response: any) => { 65 | if (response.pendingResponse) return; 66 | cancel.cancel(); 67 | 68 | const success = response.extensionResult[0].status === 1; 69 | if (!success) { 70 | const cacheStatus: CacheStatus = response.extensionResult[0].details.cacheStatus; 71 | reject(cacheStatus); 72 | return; 73 | } 74 | 75 | const data = response.extensionResult[0].extensionData; 76 | resolve(data); 77 | } 78 | ); 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/protobuf/ColorResult.ts: -------------------------------------------------------------------------------- 1 | import { PBBool, PBUInt32, PBMessage, make } from "./defs"; 2 | 3 | const Color = PBMessage({ 4 | rgb: make(1, PBUInt32), 5 | isFallback: make(2, PBBool) 6 | }); 7 | 8 | export const ColorResult = PBMessage({ 9 | colorRaw: make(1, Color), 10 | colorLight: make(2, Color), 11 | colorDark: make(3, Color) 12 | }); 13 | -------------------------------------------------------------------------------- /src/protobuf/defs.ts: -------------------------------------------------------------------------------- 1 | export class Reader { 2 | private buffer: Uint8Array; 3 | private offset: number; 4 | 5 | constructor(buffer: Uint8Array | DataView) { 6 | if (buffer instanceof DataView) buffer = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); 7 | 8 | this.buffer = buffer; 9 | this.offset = 0; 10 | } 11 | 12 | getVarIntView(): DataView { 13 | let end = this.offset; 14 | while (end < this.buffer.length - 1 && this.buffer[end] & 0x80) end++; 15 | end++; 16 | 17 | return this.getView(end - this.offset); 18 | } 19 | 20 | getVarInt(): bigint { 21 | if (this.isExhausted()) return 0n; 22 | 23 | let value = 0n; 24 | let shift = 0n; 25 | 26 | let byte: bigint; 27 | do { 28 | byte = BigInt(this.buffer[this.offset++]); 29 | 30 | value |= (byte & 0x7fn) << shift; 31 | shift += 7n; 32 | } while (byte & 0x80n && !this.isExhausted()); 33 | 34 | return value; 35 | } 36 | 37 | getArray(n: number): Uint8Array { 38 | let amount = Math.min(n, this.buffer.length - this.offset); 39 | 40 | const value = this.buffer.slice(this.offset, this.offset + amount); 41 | this.offset += amount; 42 | return value; 43 | } 44 | 45 | getView(n: number): DataView { 46 | let amount = Math.min(n, this.buffer.length - this.offset); 47 | 48 | const value = new DataView(this.buffer.buffer, this.buffer.byteOffset + this.offset, amount); 49 | this.offset += amount; 50 | return value; 51 | } 52 | 53 | has(n: number): boolean { 54 | return this.buffer.length - this.offset >= n; 55 | } 56 | 57 | isExhausted(): boolean { 58 | return !this.has(1); 59 | } 60 | } 61 | 62 | function sign(x: bigint, n: number): bigint { 63 | return x & (1n << BigInt(n * 8 - 1)) ? x - (1n << BigInt(n * 8)) : x; 64 | } 65 | 66 | export enum PBBaseType { 67 | VarInt, 68 | Fixed4, 69 | Fixed8, 70 | LDelim 71 | } 72 | 73 | type PBValueSingle = [PBBaseType, (view: DataView) => T]; 74 | type PBValueArray = [PBBaseType, (view: DataView) => T, true]; 75 | export type PBValue = PBValueSingle | (T extends any[] ? PBValueArray : never); 76 | export type PBValueTypeOf = T extends PBValue ? U : never; 77 | 78 | function readValue(reader: Reader, value: PBValue): [true, T] | [false, undefined] { 79 | switch (value[0]) { 80 | case PBBaseType.Fixed4: 81 | return reader.has(4) ? [true, value[1](reader.getView(4))] : [false, undefined]; 82 | case PBBaseType.Fixed8: 83 | return reader.has(8) ? [true, value[1](reader.getView(8))] : [false, undefined]; 84 | case PBBaseType.VarInt: 85 | return [true, value[1](reader.getVarIntView())]; 86 | case PBBaseType.LDelim: 87 | const length = Number(reader.getVarInt()); 88 | return [true, value[1](reader.getView(length))]; 89 | } 90 | } 91 | 92 | export function mapPBValue(value: PBValue, fn: (value: U) => V): PBValue { 93 | return [value[0], view => fn(value[1](view))]; 94 | } 95 | 96 | export const PBVarInt: PBValue = [PBBaseType.VarInt, view => new Reader(view).getVarInt()]; 97 | export const PBBool: PBValue = mapPBValue(PBVarInt, x => !!x); 98 | export const PBUInt32: PBValue = mapPBValue(PBVarInt, Number); 99 | export const PBUInt64: PBValue = PBVarInt; 100 | export const PBInt32: PBValue = mapPBValue(PBVarInt, x => Number(sign(x, 4))); 101 | export const PBInt64: PBValue = mapPBValue(PBVarInt, x => sign(x, 8)); 102 | 103 | export const PBFloat32: PBValue = [PBBaseType.Fixed4, view => view.getFloat32(0, true)]; 104 | export const PBFloat64: PBValue = [PBBaseType.Fixed8, view => view.getFloat64(0, true)]; 105 | export const PBFixed32: PBValue = [PBBaseType.Fixed4, view => view.getUint32(0, true)]; 106 | export const PBFixed64: PBValue = [PBBaseType.Fixed8, view => view.getBigUint64(0, true)]; 107 | export const PBSFixed32: PBValue = [PBBaseType.Fixed4, view => view.getInt32(0, true)]; 108 | export const PBSFixed64: PBValue = [PBBaseType.Fixed8, view => view.getBigInt64(0, true)]; 109 | 110 | export const PBString: PBValue = [PBBaseType.LDelim, view => new TextDecoder().decode(view)]; 111 | export const PBBytes: PBValue = [ 112 | PBBaseType.LDelim, 113 | view => new Uint8Array(view.buffer, view.byteOffset, view.byteLength) 114 | ]; 115 | 116 | export function PBEnum(values: Record): PBValue { 117 | return mapPBValue(PBVarInt, x => values[Number(x)]); 118 | } 119 | 120 | export function PBRepeated(value: PBValue): PBValue { 121 | let newType = mapPBValue(value, x => [x]); 122 | newType[2] = true; 123 | 124 | return newType; 125 | } 126 | 127 | export function PBRepeatedPacked(value: PBValue): PBValue { 128 | return [ 129 | PBBaseType.LDelim, 130 | view => { 131 | const reader = new Reader(view); 132 | const values: T[] = []; 133 | 134 | while (!reader.isExhausted()) { 135 | const [success, result] = readValue(reader, value); 136 | if (!success) break; 137 | 138 | values.push(result); 139 | } 140 | 141 | return values; 142 | } 143 | ]; 144 | } 145 | 146 | function verifyType(tag: number, expected: PBBaseType): boolean { 147 | switch (expected) { 148 | case PBBaseType.VarInt: 149 | return (tag & 7) === 0; 150 | case PBBaseType.Fixed4: 151 | return (tag & 7) === 5; 152 | case PBBaseType.Fixed8: 153 | return (tag & 7) === 1; 154 | case PBBaseType.LDelim: 155 | return (tag & 7) === 2; 156 | } 157 | } 158 | 159 | type PBMessageFields> = { 160 | [K in keyof T]: { id: number; value: PBValue }; 161 | }; 162 | type PBMessageFieldEntry = { id: number; value: PBValue }; 163 | 164 | export function make(id: number, value: PBValue): { id: number; value: PBValue } { 165 | return { id, value }; 166 | } 167 | 168 | export function PBMessage>(fields: PBMessageFields): PBValue> { 169 | return [ 170 | PBBaseType.LDelim, 171 | view => { 172 | const reader = new Reader(view); 173 | const message: Partial = {}; 174 | 175 | const fieldsArray = Object.entries>(fields).map(([name, field]) => ({ 176 | name: name as keyof T, 177 | ...field 178 | })); 179 | 180 | while (!reader.isExhausted()) { 181 | const tag = Number(reader.getVarInt()); 182 | const field = fieldsArray.find(f => f.id == tag >> 3); 183 | if (!field || !verifyType(tag, field.value[0])) break; 184 | 185 | const fieldType = field.value; 186 | 187 | const [success, value] = readValue(reader, fieldType); 188 | if (!success) break; 189 | 190 | if (fieldType[2]) { 191 | if (!message[field.name]) (message as any)[field.name] = []; 192 | (message[field.name] as any[]).push(value); 193 | } else { 194 | message[field.name] = value; 195 | } 196 | } 197 | 198 | return message; 199 | } 200 | ]; 201 | } 202 | 203 | export function parseProtobuf(data: Uint8Array, type: PBValue): T { 204 | const view = new DataView(data.buffer, data.byteOffset, data.byteLength); 205 | return type[1](view); 206 | } 207 | -------------------------------------------------------------------------------- /src/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName": { "en": "Visualizer", "en-GB": "Visualiser" }, 3 | "nameId": "visualizer", 4 | "icon": "css/icon-inactive.svg", 5 | "activeIcon": "css/icon-active.svg" 6 | } 7 | -------------------------------------------------------------------------------- /src/shaders/ncs-visualizer/blur.ts: -------------------------------------------------------------------------------- 1 | // https://developer.nvidia.com/gpugems/gpugems3/part-vi-gpu-computing/chapter-40-incremental-computation-gaussian 2 | // https://github.com/mozilla/gecko-dev/blob/23808d46cde6155213b1230675b00a0a426f466e/gfx/wr/webrender/res/cs_blur.glsl#L140-L157 3 | 4 | export const vertexShader = `#version 300 es 5 | 6 | uniform float uBlurRadius; 7 | uniform vec2 uBlurDirection; 8 | 9 | in vec2 inPosition; 10 | 11 | out vec2 fragUV; 12 | flat out vec2 fragBlurDirection; 13 | flat out int fragSupport; 14 | flat out vec3 fragGaussCoefficients; 15 | 16 | float calculateGaussianTotal(int support, vec3 fragGaussCoefficients) { 17 | float total = fragGaussCoefficients.x; 18 | for (int i = 1; i < support; i++) { 19 | fragGaussCoefficients.xy *= fragGaussCoefficients.yz; 20 | total += 2.0 * fragGaussCoefficients.x; 21 | } 22 | return total; 23 | } 24 | 25 | void main() { 26 | fragSupport = int(ceil(1.5 * uBlurRadius)) * 2; 27 | fragGaussCoefficients = vec3(1.0 / (sqrt(2.0 * 3.14159265) * uBlurRadius), exp(-0.5 / (uBlurRadius * uBlurRadius)), 0.0); 28 | fragGaussCoefficients.z = fragGaussCoefficients.y * fragGaussCoefficients.y; 29 | fragGaussCoefficients.x /= calculateGaussianTotal(fragSupport, fragGaussCoefficients); 30 | 31 | gl_Position = vec4(inPosition, 0.0, 1.0); 32 | fragUV = (inPosition + 1.0) / 2.0; 33 | fragBlurDirection = uBlurDirection; 34 | } 35 | `; 36 | export const fragmentShader = `#version 300 es 37 | precision highp float; 38 | 39 | uniform sampler2D uInputTexture; 40 | 41 | in vec2 fragUV; 42 | flat in vec2 fragBlurDirection; 43 | flat in int fragSupport; 44 | flat in vec3 fragGaussCoefficients; 45 | 46 | out float outColor; 47 | 48 | void main() { 49 | vec3 gaussCoefficients = fragGaussCoefficients; 50 | outColor = gaussCoefficients.x * texture(uInputTexture, fragUV).r; 51 | 52 | for (int i = 1; i < fragSupport; i += 2) { 53 | gaussCoefficients.xy *= gaussCoefficients.yz; 54 | float coefficientSum = gaussCoefficients.x; 55 | gaussCoefficients.xy *= gaussCoefficients.yz; 56 | coefficientSum += gaussCoefficients.x; 57 | 58 | float pixelRatio = gaussCoefficients.x / coefficientSum; 59 | vec2 offset = (float(i) + pixelRatio) * fragBlurDirection; 60 | 61 | outColor += coefficientSum * (texture(uInputTexture, fragUV + offset).r + texture(uInputTexture, fragUV - offset).r); 62 | } 63 | } 64 | `; 65 | -------------------------------------------------------------------------------- /src/shaders/ncs-visualizer/dot.ts: -------------------------------------------------------------------------------- 1 | export const vertexShader = `#version 300 es 2 | 3 | uniform int uDotCount; 4 | uniform float uDotRadius; 5 | uniform float uDotRadiusPX; 6 | 7 | uniform sampler2D uParticleTexture; 8 | 9 | in vec2 inPosition; 10 | 11 | out vec2 fragUV; 12 | out float fragDotRadiusPX; 13 | 14 | void main() { 15 | ivec2 dotIndex = ivec2(gl_InstanceID % uDotCount, gl_InstanceID / uDotCount); 16 | vec2 dotCenter = texelFetch(uParticleTexture, dotIndex, 0).xy; 17 | 18 | gl_Position = vec4(dotCenter + inPosition * uDotRadius * (1.0 + 1.0 / uDotRadiusPX), 0.0, 1.0); 19 | fragUV = inPosition; 20 | fragDotRadiusPX = uDotRadiusPX + 1.0; 21 | } 22 | `; 23 | export const fragmentShader = `#version 300 es 24 | precision highp float; 25 | 26 | in vec2 fragUV; 27 | in float fragDotRadiusPX; 28 | out float outColor; 29 | 30 | void main() { 31 | float t = clamp((1.0 - length(fragUV)) * fragDotRadiusPX, 0.0, 1.0); 32 | outColor = t; 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /src/shaders/ncs-visualizer/finalize.ts: -------------------------------------------------------------------------------- 1 | export const vertexShader = `#version 300 es 2 | 3 | uniform vec3 uOutputColor; 4 | in vec2 inPosition; 5 | 6 | out vec2 fragUV; 7 | out vec3 fragOutputColor; 8 | 9 | void main() { 10 | gl_Position = vec4(inPosition, 0.0, 1.0); 11 | fragUV = (inPosition + 1.0) / 2.0; 12 | fragOutputColor = uOutputColor; 13 | } 14 | `; 15 | export const fragmentShader = `#version 300 es 16 | precision highp float; 17 | 18 | uniform sampler2D uBlurredTexture; 19 | uniform sampler2D uOriginalTexture; 20 | 21 | in vec2 fragUV; 22 | in vec3 fragOutputColor; 23 | 24 | out vec4 outColor; 25 | 26 | void main() { 27 | float value = max(texture(uBlurredTexture, fragUV).r, texture(uOriginalTexture, fragUV).r); 28 | outColor = vec4(fragOutputColor * value, value); 29 | } 30 | `; 31 | -------------------------------------------------------------------------------- /src/shaders/ncs-visualizer/particle.ts: -------------------------------------------------------------------------------- 1 | export const vertexShader = `#version 300 es 2 | 3 | in vec2 inPosition; 4 | out vec2 fragUV; 5 | 6 | void main() { 7 | gl_Position = vec4(inPosition, 0.0, 1.0); 8 | fragUV = (inPosition + 1.0) / 2.0; 9 | } 10 | `; 11 | export const fragmentShader = `#version 300 es 12 | precision highp float; 13 | 14 | uniform float uNoiseOffset; 15 | uniform float uAmplitude; 16 | uniform int uSeed; 17 | 18 | uniform float uDotSpacing; 19 | uniform float uDotOffset; 20 | 21 | uniform float uSphereRadius; 22 | uniform float uFeather; 23 | 24 | uniform float uNoiseFrequency; 25 | uniform float uNoiseAmplitude; 26 | 27 | in vec2 fragUV; 28 | out vec2 outColor; 29 | 30 | // https://github.com/Auburn/FastNoiseLite 31 | 32 | const float FREQUENCY = 0.01; 33 | 34 | const float GAIN = 0.5; 35 | const float LACUNARITY = 1.5; 36 | const float FRACTAL_BOUNDING = 1.0 / 1.75; 37 | 38 | const ivec3 PRIMES = ivec3(501125321, 1136930381, 1720413743); 39 | 40 | const float GRADIENTS_3D[] = float[]( 41 | 0., 1., 1., 0., 0.,-1., 1., 0., 0., 1.,-1., 0., 0.,-1.,-1., 0., 42 | 1., 0., 1., 0., -1., 0., 1., 0., 1., 0.,-1., 0., -1., 0.,-1., 0., 43 | 1., 1., 0., 0., -1., 1., 0., 0., 1.,-1., 0., 0., -1.,-1., 0., 0., 44 | 0., 1., 1., 0., 0.,-1., 1., 0., 0., 1.,-1., 0., 0.,-1.,-1., 0., 45 | 1., 0., 1., 0., -1., 0., 1., 0., 1., 0.,-1., 0., -1., 0.,-1., 0., 46 | 1., 1., 0., 0., -1., 1., 0., 0., 1.,-1., 0., 0., -1.,-1., 0., 0., 47 | 0., 1., 1., 0., 0.,-1., 1., 0., 0., 1.,-1., 0., 0.,-1.,-1., 0., 48 | 1., 0., 1., 0., -1., 0., 1., 0., 1., 0.,-1., 0., -1., 0.,-1., 0., 49 | 1., 1., 0., 0., -1., 1., 0., 0., 1.,-1., 0., 0., -1.,-1., 0., 0., 50 | 0., 1., 1., 0., 0.,-1., 1., 0., 0., 1.,-1., 0., 0.,-1.,-1., 0., 51 | 1., 0., 1., 0., -1., 0., 1., 0., 1., 0.,-1., 0., -1., 0.,-1., 0., 52 | 1., 1., 0., 0., -1., 1., 0., 0., 1.,-1., 0., 0., -1.,-1., 0., 0., 53 | 0., 1., 1., 0., 0.,-1., 1., 0., 0., 1.,-1., 0., 0.,-1.,-1., 0., 54 | 1., 0., 1., 0., -1., 0., 1., 0., 1., 0.,-1., 0., -1., 0.,-1., 0., 55 | 1., 1., 0., 0., -1., 1., 0., 0., 1.,-1., 0., 0., -1.,-1., 0., 0., 56 | 1., 1., 0., 0., 0.,-1., 1., 0., -1., 1., 0., 0., 0.,-1.,-1., 0. 57 | ); 58 | 59 | float smootherStep(float t) { 60 | return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); 61 | } 62 | vec3 smootherStep(vec3 coord) { 63 | return vec3(smootherStep(coord.x), smootherStep(coord.y), smootherStep(coord.z)); 64 | } 65 | 66 | int hash(int seed, ivec3 primed) { 67 | return (seed ^ primed.x ^ primed.y ^ primed.z) * 0x27d4eb2d; 68 | } 69 | 70 | float gradCoord(int seed, ivec3 primed, vec3 d) { 71 | int hash = hash(seed, primed); 72 | hash ^= hash >> 15; 73 | hash &= 63 << 2; 74 | return d.x * GRADIENTS_3D[hash] + d.y * GRADIENTS_3D[hash | 1] + d.z * GRADIENTS_3D[hash | 2]; 75 | } 76 | 77 | float perlinSingle(int seed, vec3 coord) { 78 | ivec3 coord0 = ivec3(floor(coord)); 79 | vec3 d0 = coord - vec3(coord0); 80 | vec3 d1 = d0 - 1.0; 81 | vec3 s = smootherStep(d0); 82 | coord0 *= PRIMES; 83 | ivec3 coord1 = coord0 + PRIMES; 84 | float xf00 = mix(gradCoord(seed, coord0, d0), gradCoord(seed, ivec3(coord1.x, coord0.yz), vec3(d1.x, d0.yz)), s.x); 85 | float xf10 = mix(gradCoord(seed, ivec3(coord0.x, coord1.y, coord0.z), vec3(d0.x, d1.y, d0.z)), gradCoord(seed, ivec3(coord1.xy, coord0.z), vec3(d1.xy, d0.z)), s.x); 86 | float xf01 = mix(gradCoord(seed, ivec3(coord0.xy, coord1.z), vec3(d0.xy, d1.z)), gradCoord(seed, ivec3(coord1.x, coord0.y, coord1.z), vec3(d1.x, d0.y, d1.z)), s.x); 87 | float xf11 = mix(gradCoord(seed, ivec3(coord0.x, coord1.yz), vec3(d0.x, d1.yz)), gradCoord(seed, coord1, d1), s.x); 88 | float yf0 = mix(xf00, xf10, s.y); 89 | float yf1 = mix(xf01, xf11, s.y); 90 | return mix(yf0, yf1, s.z) * 0.964921414852142333984375f; 91 | } 92 | 93 | float fractalNoise(vec3 coord) { 94 | return perlinSingle(uSeed, coord) * FRACTAL_BOUNDING 95 | + perlinSingle(uSeed + 1, coord * LACUNARITY) * FRACTAL_BOUNDING * GAIN 96 | + perlinSingle(uSeed + 2, coord * LACUNARITY * LACUNARITY) * FRACTAL_BOUNDING * GAIN * GAIN; 97 | } 98 | 99 | void main() { 100 | float noise = fractalNoise(vec3(fragUV * uNoiseFrequency, uNoiseOffset)) * uNoiseAmplitude; 101 | vec3 dotCenter = vec3(fragUV * uDotSpacing + uDotOffset + noise, (noise + 0.5 * uNoiseAmplitude) * uAmplitude * 0.4); 102 | 103 | float distanceFromCenter = length(dotCenter); 104 | dotCenter /= distanceFromCenter; 105 | distanceFromCenter = min(uSphereRadius, distanceFromCenter); 106 | dotCenter *= distanceFromCenter; 107 | 108 | float featherRadius = uSphereRadius - uFeather; 109 | float featherStrength = 1.0 - clamp((distanceFromCenter - featherRadius) / uFeather, 0.0, 1.0); 110 | dotCenter *= featherStrength * (uSphereRadius / distanceFromCenter - 1.0) + 1.0; 111 | 112 | dotCenter.y *= -1.0; 113 | outColor = dotCenter.xy; 114 | } 115 | `; 116 | -------------------------------------------------------------------------------- /src/types/css-modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.css" { 2 | const classes: { [key: string]: string }; 3 | export default classes; 4 | } 5 | 6 | declare module "*.module.scss" { 7 | const classes: { [key: string]: string }; 8 | export default classes; 9 | } 10 | -------------------------------------------------------------------------------- /src/types/fastnoise-lite.d.ts: -------------------------------------------------------------------------------- 1 | declare module "fastnoise-lite"; 2 | 3 | namespace FastNoise { 4 | enum NoiseType { 5 | OpenSimplex2, 6 | OpenSimplex2S, 7 | Cellular, 8 | Perlin, 9 | ValueCubic, 10 | Value 11 | } 12 | 13 | enum RotationType3D { 14 | None, 15 | ImproveXYPlanes, 16 | ImproveXZPlanes 17 | } 18 | 19 | enum FractalType { 20 | None, 21 | FBm, 22 | Ridged, 23 | PingPong, 24 | DomainWarpProgressive, 25 | DomainWarpIndependent 26 | } 27 | 28 | enum CellularDistanceFunction { 29 | Euclidean, 30 | EuclideanSq, 31 | Manhattan, 32 | Hybrid 33 | } 34 | 35 | enum CellularReturnType { 36 | CellValue, 37 | Distance, 38 | Distance2, 39 | Distance2Add, 40 | Distance2Sub, 41 | Distance2Mul, 42 | Distance2Div 43 | } 44 | 45 | enum DomainWarpType { 46 | OpenSimplex2, 47 | OpenSimplex2Reduced, 48 | BasicGrid 49 | } 50 | 51 | enum TransformType3D { 52 | None, 53 | ImproveXYPlanes, 54 | ImproveXZPlanes, 55 | DefaultOpenSimplex2 56 | } 57 | } 58 | 59 | class FastNoise { 60 | constructor(seed: number); 61 | 62 | SetSeed(seed: number): void; 63 | SetFrequency(frequency: number): void; 64 | SetNoiseType(noiseType: FastNoise.NoiseType): void; 65 | SetRotationType3D(rotationType3D: FastNoise.RotationType3D): void; 66 | SetFractalType(fractalType: FastNoise.FractalType): void; 67 | SetFractalOctaves(octaves: number): void; 68 | SetFractalLacunarity(lacunarity: number): void; 69 | SetFractalGain(gain: number): void; 70 | SetFractalWeightedStrength(weightedStrength: number): void; 71 | SetFractalPingPongStrength(pingPongStrength: number): void; 72 | SetCellularDistanceFunction(cellularDistanceFunction: FastNoise.CellularDistanceFunction): void; 73 | SetCellularReturnType(cellularReturnType: FastNoise.CellularReturnType): void; 74 | SetCellularJitter(cellularJitter: number): void; 75 | SetDomainWarpType(domainWarpType: FastNoise.DomainWarpType): void; 76 | SetDomainWarpAmp(domainWarpAmp: number): void; 77 | 78 | GetNoise(x: number, y: number, z?: number): number; 79 | DomainWrap(coord: Vector2 | Vector3): void; 80 | } 81 | 82 | class Vector2 { 83 | x: number; 84 | y: number; 85 | 86 | constructor(x: number, y: number); 87 | } 88 | 89 | class Vector3 { 90 | x: number; 91 | y: number; 92 | z: number; 93 | 94 | constructor(x: number, y: number, z: number); 95 | } 96 | 97 | export default FastNoise; 98 | -------------------------------------------------------------------------------- /src/types/types.d.ts: -------------------------------------------------------------------------------- 1 | type PlayerEventListener = Parameters[1]; 2 | 3 | namespace SpotifyAudioAnalysis { 4 | interface Meta { 5 | analyzer_version: string; 6 | platform: string; 7 | detailed_status: string; 8 | status_code: number; 9 | timestamp: number; 10 | analysis_time: number; 11 | input_process: string; 12 | } 13 | 14 | interface Track { 15 | num_samples: number; 16 | duration: number; 17 | sample_md5: string; 18 | offset_seconds: number; 19 | window_seconds: number; 20 | analysis_sample_rate: number; 21 | analysis_channels: number; 22 | end_of_fade_in: number; 23 | start_of_fade_out: number; 24 | loudness: number; 25 | tempo: number; 26 | tempo_confidence: number; 27 | time_signature: number; 28 | time_signature_confidence: number; 29 | key: number; 30 | key_confidence: number; 31 | mode: number; 32 | mode_confidence: number; 33 | codestring: string; 34 | code_version: number; 35 | echoprintstring: string; 36 | echoprint_version: number; 37 | synchstring: string; 38 | synch_version: number; 39 | rhythmstring: string; 40 | rhythm_version: number; 41 | } 42 | 43 | interface Bar { 44 | start: number; 45 | duration: number; 46 | confidence: number; 47 | } 48 | 49 | interface Beat { 50 | start: number; 51 | duration: number; 52 | confidence: number; 53 | } 54 | 55 | interface Section { 56 | start: number; 57 | duration: number; 58 | confidence: number; 59 | loudness: number; 60 | tempo: number; 61 | tempo_confidence: number; 62 | key: number; 63 | key_confidence: number; 64 | mode: number; 65 | mode_confidence: number; 66 | time_signature: number; 67 | time_signature_confidence: number; 68 | } 69 | 70 | interface Segment { 71 | start: number; 72 | duration: number; 73 | confidence: number; 74 | loudness_start: number; 75 | loudness_max: number; 76 | loudness_max_time: number; 77 | loudness_end: number; 78 | pitches: [number, number, number, number, number, number, number, number, number, number, number, number]; 79 | timbre: [number, number, number, number, number, number, number, number, number, number, number, number]; 80 | } 81 | 82 | interface Tatum { 83 | start: number; 84 | duration: number; 85 | confidence: number; 86 | } 87 | } 88 | 89 | interface SpotifyAudioAnalysis { 90 | meta: SpotifyAudioAnalysis.Meta; 91 | track: SpotifyAudioAnalysis.Track; 92 | bars: SpotifyAudioAnalysis.Bar[]; 93 | beats: SpotifyAudioAnalysis.Beat[]; 94 | sections: SpotifyAudioAnalysis.Section[]; 95 | segments: SpotifyAudioAnalysis.Segment[]; 96 | tatums: SpotifyAudioAnalysis.Tatum[]; 97 | } 98 | 99 | interface CurveEntry { 100 | x: number; 101 | y: number; 102 | accumulatedIntegral?: number; 103 | } 104 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function binarySearchIndex(array: T[], converter: (value: T, index: number) => number, position: number): number { 2 | let lowerBound = 0; 3 | let upperBound = array.length; 4 | 5 | while (upperBound - lowerBound > 1) { 6 | const testIndex = Math.floor((upperBound + lowerBound) / 2); 7 | const pointPos = converter(array[testIndex], testIndex); 8 | 9 | if (pointPos <= position) lowerBound = testIndex; 10 | else upperBound = testIndex; 11 | } 12 | 13 | return lowerBound; 14 | } 15 | 16 | export function decibelsToAmplitude(decibels: number): number { 17 | return Math.min(Math.max(Math.pow(10, decibels / 20), 0), 1); 18 | } 19 | 20 | export function smoothstep(x: number): number { 21 | //return x * x * x * (3 * x * (2 * x - 5) + 10); 22 | return x * x * (3 - 2 * x); 23 | } 24 | 25 | export function mapLinear(value: number, iMin: number, iMax: number, oMin: number, oMax: number): number { 26 | value = (value - iMin) / (iMax - iMin); 27 | value = value * (oMax - oMin) + oMin; 28 | return value; 29 | } 30 | 31 | export function map( 32 | value: number, 33 | iMin: number, 34 | iMax: number, 35 | interpolate: (x: number) => number, 36 | oMin: number, 37 | oMax: number 38 | ): number { 39 | value = (value - iMin) / (iMax - iMin); 40 | value = interpolate(value); 41 | value = value * (oMax - oMin) + oMin; 42 | return value; 43 | } 44 | 45 | // calculate the integral of the linear function through p1 and p2 between p1.x and p2.x 46 | export function integrateLinearSegment(p1: CurveEntry, p2: CurveEntry): number { 47 | return -0.5 * (p1.x - p2.x) * (p1.y + p2.y); 48 | } 49 | 50 | export function sampleSegmentedFunction( 51 | array: T[], 52 | getX: (value: T, index: number) => number, 53 | getY: (value: T, index: number) => number, 54 | interpolate: (x: number) => number, 55 | position: number 56 | ): number { 57 | const pointIndex = binarySearchIndex(array, getX, position); 58 | const point = array[pointIndex]; 59 | 60 | if (pointIndex > array.length - 2) return getY(point, pointIndex); 61 | const nextPoint = array[pointIndex + 1]; 62 | 63 | return map( 64 | position, 65 | getX(point, pointIndex), 66 | getX(nextPoint, pointIndex + 1), 67 | interpolate, 68 | getY(point, pointIndex), 69 | getY(nextPoint, pointIndex + 1) 70 | ); 71 | } 72 | 73 | export function sampleAmplitudeMovingAverage(amplitudeCurve: CurveEntry[], position: number, windowSize: number): number { 74 | if (windowSize == 0) 75 | return sampleSegmentedFunction( 76 | amplitudeCurve, 77 | e => e.x, 78 | e => e.y, 79 | x => x, 80 | position 81 | ); 82 | 83 | const windowStart = position - windowSize / 2; 84 | const windowEnd = position + windowSize / 2; 85 | const windowStartIndex = binarySearchIndex(amplitudeCurve, e => e.x, windowStart); 86 | const windowEndIndex = binarySearchIndex(amplitudeCurve, e => e.x, windowEnd); 87 | 88 | let integral = 0; 89 | if (windowStartIndex == windowEndIndex) { 90 | const p1 = amplitudeCurve[windowStartIndex]; 91 | 92 | if (windowStartIndex > amplitudeCurve.length - 2) return p1.y; 93 | const p2 = amplitudeCurve[windowStartIndex + 1]; 94 | 95 | const yA = mapLinear(windowStart, p1.x, p2.x, p1.y, p2.y); 96 | const yB = mapLinear(windowEnd, p1.x, p2.x, p1.y, p2.y); 97 | 98 | return (yA + yB) / 2; 99 | } else { 100 | let p1 = amplitudeCurve[windowStartIndex]; 101 | let p2 = amplitudeCurve[windowStartIndex + 1]; 102 | 103 | let p = { x: windowStart, y: mapLinear(windowStart, p1.x, p2.x, p1.y, p2.y) }; 104 | integral = integrateLinearSegment(p, p2); 105 | 106 | for (let i = windowStartIndex + 1; i < windowEndIndex; i++) { 107 | p1 = p2; 108 | p2 = amplitudeCurve[i + 1]; 109 | 110 | integral += integrateLinearSegment(p1, p2); 111 | } 112 | 113 | p1 = p2; 114 | if (windowEndIndex > amplitudeCurve.length - 2) { 115 | integral += p1.y * (windowEnd - p1.x); 116 | } else { 117 | p2 = amplitudeCurve[windowEndIndex + 1]; 118 | p = { x: windowEnd, y: mapLinear(windowEnd, p1.x, p2.x, p1.y, p2.y) }; 119 | integral += integrateLinearSegment(p1, p); 120 | } 121 | } 122 | 123 | return integral / windowSize; 124 | } 125 | 126 | export function sampleAccumulatedIntegral(amplitudeCurve: CurveEntry[], position: number) { 127 | const index = binarySearchIndex(amplitudeCurve, e => e.x, position); 128 | const p1 = amplitudeCurve[index]; 129 | 130 | if (index + 1 >= amplitudeCurve.length) return (p1.accumulatedIntegral ?? 0) + p1.y * (position - p1.x); 131 | 132 | const p2 = amplitudeCurve[index + 1]; 133 | const mid = { 134 | x: position, 135 | y: mapLinear(position, p1.x, p2.x, p1.y, p2.y) 136 | }; 137 | 138 | return (p1.accumulatedIntegral ?? 0) + integrateLinearSegment(p1, mid); 139 | } 140 | -------------------------------------------------------------------------------- /src/window.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import App from "./app"; 3 | 4 | export function createVisualizerWindow(rendererId: string) { 5 | try { 6 | const win = window.open(); 7 | if (!win) return false; 8 | 9 | document.querySelectorAll("link[rel=stylesheet]").forEach(e => { 10 | const newElement = win.document.createElement("link"); 11 | newElement.setAttribute("rel", "stylesheet"); 12 | newElement.setAttribute("href", (e as HTMLLinkElement).href); 13 | 14 | win.document.head.appendChild(newElement); 15 | }); 16 | document.querySelectorAll("style").forEach(e => { 17 | const newElement = win.document.createElement("style"); 18 | newElement.innerText = e.innerText; 19 | 20 | win.document.head.appendChild(newElement); 21 | }); 22 | 23 | win.document.documentElement.className = document.documentElement.className; 24 | win.document.body.className = document.body.className; 25 | 26 | Spicetify.ReactDOM.render(, win.document.body); 27 | 28 | return true; 29 | } catch { 30 | return false; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["ES2020", "DOM"], 5 | "jsx": "react", 6 | "module": "commonjs", 7 | "resolveJsonModule": true, 8 | "outDir": "dist", 9 | "typeRoots": ["./src/types/"], 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "strict": true, 13 | "skipLibCheck": true 14 | }, 15 | "include": ["./src/**/*.*"] 16 | } 17 | --------------------------------------------------------------------------------