├── .github └── workflows │ └── deno.yml ├── .gitignore ├── .hintrc ├── .vscode ├── launch.json └── settings.json ├── Platform.d.ts ├── README.md ├── Spicetify.d.ts ├── deno.json ├── dist ├── bad-lyrics │ ├── app.js │ ├── app.js.map │ └── prism.mjs ├── corbeille │ ├── app.js │ ├── app.js.map │ └── prism.mjs ├── detect-duplicates │ ├── app.js │ ├── app.js.map │ └── prism.mjs ├── keyboard-shortcuts │ ├── app.js │ ├── app.js.map │ └── prism.mjs ├── play-enhanced-songs │ ├── app.js │ ├── app.js.map │ └── prism.mjs ├── search-on-youtube │ ├── app.js │ ├── app.js.map │ └── prism.mjs ├── show-the-genres │ ├── app.css │ ├── app.css.map │ ├── app.js │ ├── app.js.map │ └── prism.mjs ├── snippets │ ├── compact-left-sidebar.css │ ├── compact-queue-panel.css │ ├── compact-tracklist.css │ ├── horizontal-nav-links.css │ ├── lobotomize-beautiful-lyrics.css │ ├── scrollable-spicetify-playlist-labels.css │ ├── thin-sidebar.css │ └── topbar-inside-titlebar.css ├── sort-plus │ ├── app.js │ ├── app.js.map │ └── prism.mjs ├── spoqify-radios │ ├── app.js │ ├── app.js.map │ └── prism.mjs ├── star-ratings-2 │ ├── app.css │ ├── app.css.map │ ├── app.js │ ├── app.js.map │ └── prism.mjs ├── star-ratings │ ├── app.js │ ├── app.js.map │ └── prism.mjs └── vaultify │ ├── app.js │ ├── app.js.map │ └── prism.mjs ├── extensions ├── bad-lyrics │ ├── app.ts │ ├── assets │ │ ├── README.md │ │ └── preview.gif │ ├── components │ │ ├── components.ts │ │ ├── contexts.ts │ │ └── mixins.ts │ ├── pkgs │ │ └── spring.ts │ ├── splines │ │ ├── catmullRomSpline.ts │ │ ├── monotoneNormalSpline.ts │ │ └── splines.ts │ └── utils │ │ ├── .DS_Store │ │ ├── LyricsProvider.ts │ │ ├── PlayerW.ts │ │ └── Song.ts ├── corbeille │ ├── app.ts │ ├── assets │ │ └── README.md │ └── settings.ts ├── detect-duplicates │ ├── app.ts │ └── assets │ │ └── README.md ├── keyboard-shortcuts │ ├── app.ts │ ├── assets │ │ ├── README.md │ │ └── preview.png │ ├── sneak.ts │ └── util.ts ├── play-enhanced-songs │ ├── app.ts │ └── assets │ │ ├── README.md │ │ └── preview.png ├── search-on-youtube │ ├── app.ts │ ├── assets │ │ ├── README.md │ │ └── preview.png │ └── settings.ts ├── show-the-genres │ ├── app.ts │ ├── assets │ │ ├── README.md │ │ ├── preview.gif │ │ ├── preview.png │ │ ├── styles.css │ │ └── styles.scss │ ├── components.ts │ └── settings.ts ├── sort-plus │ ├── app.ts │ ├── assets │ │ ├── README.md │ │ └── preview.png │ ├── fetch.ts │ ├── playlistsInterop.ts │ ├── populate.ts │ ├── settings.ts │ └── util.ts ├── spoqify-radios │ ├── app.ts │ ├── assets │ │ ├── README.md │ │ └── preview.png │ └── settings.ts ├── star-ratings-2 │ ├── app.ts │ ├── assets │ │ ├── README.md │ │ ├── preview.png │ │ ├── styles.css │ │ └── styles.scss │ ├── controls.tsx │ ├── dropdown.tsx │ ├── ratings.ts │ ├── settings.ts │ └── util.ts ├── star-ratings │ ├── app.ts │ ├── assets │ │ ├── README.md │ │ └── preview.png │ ├── components.ts │ ├── settings.ts │ └── util.ts └── vaultify │ ├── app.ts │ ├── assets │ ├── README.md │ └── preview.png │ ├── backup.ts │ ├── restore.ts │ ├── settings.ts │ └── util.ts ├── global.d.ts ├── manifest.json ├── shared ├── GraphQL │ ├── Definitions │ │ └── searchTracks.ts │ ├── fetchAlbum.ts │ ├── fetchArtistDiscography.ts │ ├── fetchArtistOveriew.ts │ ├── fetchArtistRelated.ts │ ├── searchModalResults.ts │ ├── searchTracks.ts │ └── sharedTypes.ts ├── api.ts ├── deps.ts ├── fp.ts ├── listeners.ts ├── lscache.ts ├── math.ts ├── modules.ts ├── parse.ts ├── platformApi.ts ├── settings.tsx └── util.ts ├── snippets ├── compact-left-sidebar.scss ├── compact-queue-panel.scss ├── compact-tracklist.scss ├── horizontal-nav-links.scss ├── lobotomize-beautiful-lyrics.scss ├── scrollable-spicetify-playlist-labels.scss ├── thin-sidebar.scss └── topbar-inside-titlebar.scss ├── tasks ├── bundle.ts ├── debug.ts ├── esbuild-plugin-postcss.ts └── front-matter.ts └── tmp.js /.github/workflows/deno.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow will install Deno then run `deno lint` and `deno test`. 7 | # For more information see: https://github.com/denoland/setup-deno 8 | 9 | name: Deno 10 | 11 | on: 12 | push: 13 | tags: 14 | - v* 15 | 16 | permissions: 17 | contents: write 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Setup repo 25 | uses: actions/checkout@v3 26 | 27 | - name: Set up Deno 28 | uses: denoland/setup-deno@v1 29 | with: 30 | deno-version: v1.x 31 | 32 | - name: Bundle with Deno 33 | run: deno task bundle 34 | 35 | - name: Rename and Upload App.js files 36 | run: | 37 | cd dist 38 | for folder in */; do 39 | folder=${folder%*/} 40 | if [ "$folder" != "snippets" ]; then 41 | mv "${folder}/app.js" "${folder}/${folder}.app.js" 42 | fi 43 | done 44 | 45 | - name: Create Release 46 | uses: softprops/action-gh-release@v1 47 | if: startsWith(github.ref, 'refs/tags/') 48 | with: 49 | files: | 50 | dist/**/*.app.js 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | tmp/** 3 | extensions/redacted/** 4 | deno.lock 5 | -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "development" 4 | ], 5 | "hints": { 6 | "axe/name-role-value": [ 7 | "default", 8 | { 9 | "button-name": "off" 10 | } 11 | ], 12 | "axe/forms": [ 13 | "default", 14 | { 15 | "label": "off" 16 | } 17 | ], 18 | "compat-api/css": "off" 19 | } 20 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach to Spotify", 9 | "port": 9222, 10 | "request": "attach", 11 | "type": "chrome", 12 | "webRoot": "${workspaceFolder}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": true 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NOTICE 2 | ~~I'm moving all of my extensions and porting some other famous ones over to my fork of spicetify-cli available at https://github.com/Delusoire/bespoke. As such, this repo is in maintenance mode, no new extenions/features will be added to this repo and the support will be very limited.~~ 3 | 4 | This repository is now outdated as spicetify-v3 (codenamed bespoke) is in beta stage. These extensions [have been updated here](https://github.com/Delusoire/bespoke-modules) to work for the newer spicetify release where they'll still be getting new features and bug fixes. However, this repo will be archived. 5 | # 6 | 7 | ### Practical Spicetify Extensions 8 | 9 | Install using [marketplace](https://github.com/spicetify/spicetify-marketplace) OR by downloading either: 10 | - dist/extension-name/prism.js (online only) 11 | - dist/extension-name/app.js (no auto-updates, offline) 12 | 13 | and putting them in your Spicetify extensions folder. 14 | 15 | ## Project Structure 16 | `extensions/` contains the code for each extension, you can find each extension's README under `extension//assets/` 17 | 18 | `snippets/` contains stylesheets for snippets, these are written in scss, if you want to use them then you have to grab the transpiled files from `dist/snippets/` 19 | 20 | `dist/` contains all the ready to use bundles for extensions & snippets 21 | 22 | ## Building 23 | it is as simple as installing the latest [deno](https://github.com/denoland/deno_install) and running `deno task bundle` 24 | the manifest for [marketplace](https://github.com/spicetify/spicetify-marketplace) is automatically generated as part of the build step 25 | 26 | ## Notes 27 | Most of these extensions are functionally similar to older work from: 28 | - https://github.com/Tetrax-10/Spicetify-Extensions 29 | - https://github.com/duffey/spicetify-star-ratings 30 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "bundle": "deno run --allow-read --allow-net --allow-run --allow-write --allow-env tasks/bundle.ts", 4 | "debug": "deno run --allow-run --allow-env tasks/debug.ts" 5 | }, 6 | "compilerOptions": { 7 | "lib": [ 8 | "ESNext", 9 | "DOM", 10 | "npm:@types/react", 11 | "npm:@types/react-dom", 12 | "npm:@types/mousetrap", 13 | "./globals.d.ts" 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /dist/bad-lyrics/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/bad-lyrics/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /dist/corbeille/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/corbeille/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /dist/detect-duplicates/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/detect-duplicates/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /dist/keyboard-shortcuts/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/keyboard-shortcuts/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /dist/play-enhanced-songs/app.js: -------------------------------------------------------------------------------- 1 | // shared/util.ts 2 | var { URI } = Spicetify; 3 | var { PlayerAPI } = Spicetify.Platform; 4 | var mainElement = document.querySelector("main"); 5 | var [REACT_FIBER, REACT_PROPS] = Object.keys(mainElement); 6 | 7 | // shared/platformApi.ts 8 | var { CosmosAsync } = Spicetify; 9 | var { LibraryAPI, PlaylistAPI, RootlistAPI, PlaylistPermissionsAPI, EnhanceAPI, LocalFilesAPI } = Spicetify.Platform; 10 | var fetchPlaylistEnhancedSongs300 = async (uri, offset = 0, limit = 300) => (await EnhanceAPI.getPage( 11 | uri, 12 | /* iteration */ 13 | 0, 14 | /* sessionId */ 15 | 0, 16 | offset, 17 | limit 18 | )).enhancePage.pageItems; 19 | var fetchPlaylistEnhancedSongs = async (uri, offset = 0) => { 20 | const nextPageItems = await fetchPlaylistEnhancedSongs300(uri, offset); 21 | if (nextPageItems?.length < 300) 22 | return nextPageItems; 23 | else 24 | return nextPageItems.concat(await fetchPlaylistEnhancedSongs(uri, offset + 300)); 25 | }; 26 | 27 | // extensions/play-enhanced-songs/app.ts 28 | var { URI: URI2, ContextMenu } = Spicetify; 29 | var { PlayerAPI: PlayerAPI2 } = Spicetify.Platform; 30 | var playEnhancedSongs = async (uri) => { 31 | const queue = await fetchPlaylistEnhancedSongs(uri); 32 | PlayerAPI2.clearQueue(); 33 | PlayerAPI2.addToQueue(queue); 34 | }; 35 | new ContextMenu.Item( 36 | "Play enhanced songs", 37 | ([uri]) => playEnhancedSongs(uri), 38 | ([uri]) => URI2.isPlaylistV1OrV2(uri), 39 | "enhance" 40 | ).register(); 41 | -------------------------------------------------------------------------------- /dist/play-enhanced-songs/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/play-enhanced-songs/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /dist/search-on-youtube/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/search-on-youtube/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /dist/show-the-genres/app.css: -------------------------------------------------------------------------------- 1 | /* extensions/show-the-genres/assets/styles.css */ 2 | .main-nowPlayingWidget-trackInfo.main-trackInfo-container { 3 | grid-template: "title title" "badges subtitle" "genres genres"/auto 1fr auto; 4 | } 5 | -------------------------------------------------------------------------------- /dist/show-the-genres/app.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": ["../../extensions/show-the-genres/assets/styles.css"], 4 | "sourcesContent": [".main-nowPlayingWidget-trackInfo.main-trackInfo-container{grid-template:\"title title\" \"badges subtitle\" \"genres genres\"/auto 1fr auto}"], 5 | "mappings": ";AAAA,CAAC,+BAA+B,CAAC;AAAyB,iBAAc,cAAc,kBAAkB,eAAe,CAAC,KAAK,IAAI;AAAI;", 6 | "names": [] 7 | } 8 | -------------------------------------------------------------------------------- /dist/show-the-genres/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/show-the-genres/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /dist/snippets/compact-left-sidebar.css: -------------------------------------------------------------------------------- 1 | .main-yourLibraryX-navItems{padding:4px 0px}.main-yourLibraryX-collapseButton>button[aria-label="Collapse Your Library"]{visibility:collapse}.main-yourLibraryX-collapseButton>button[aria-label="Collapse Your Library"]>span{visibility:visible} -------------------------------------------------------------------------------- /dist/snippets/compact-queue-panel.css: -------------------------------------------------------------------------------- 1 | .main-buddyFeed-content.queue-panel{padding:10px}.queue-panel .queue-queuePage-queuePage{margin-top:10px !important}.queue-queuePage-subHeader{margin-top:8px}.queue-panel .main-trackList-trackListRowGrid{padding:0px 4px} -------------------------------------------------------------------------------- /dist/snippets/horizontal-nav-links.css: -------------------------------------------------------------------------------- 1 | #spicetify-sticky-list{display:flex;flex-wrap:wrap;align-items:center;justify-content:center}#spicetify-sticky-list span{display:none} -------------------------------------------------------------------------------- /dist/snippets/lobotomize-beautiful-lyrics.css: -------------------------------------------------------------------------------- 1 | .lyrics-background-container{display:none} -------------------------------------------------------------------------------- /dist/snippets/scrollable-spicetify-playlist-labels.css: -------------------------------------------------------------------------------- 1 | .spicetify-playlist-labels-labels-container{height:unset !important;overflow-x:scroll !important;overflow-y:hidden !important}.spicetify-playlist-labels-labels-container::-webkit-scrollbar{display:none} -------------------------------------------------------------------------------- /dist/snippets/thin-sidebar.css: -------------------------------------------------------------------------------- 1 | .main-yourLibraryX-libraryRootlist:not(.main-yourLibraryX-libraryIsCollapsed) .main-yourLibraryX-listItem .x-entityImage-imageContainer,.main-yourLibraryX-libraryRootlist:not(.main-yourLibraryX-libraryIsCollapsed) .main-yourLibraryX-rowCover{width:1.6em !important;height:1.6em !important}.main-yourLibraryX-listItemGroup{grid-template-rows:none !important}.main-yourLibraryX-listItemGroup *{padding-block:0}.main-yourLibraryX-listItem [role=group]{min-block-size:0 !important}.main-yourLibraryX-listItem .HeaderArea .Column{flex-direction:row;gap:.5em}.main-yourLibraryX-listItem .HeaderArea *{padding-top:0 !important;padding-bottom:0 !important}.main-yourLibraryX-listRowSubtitle{padding-top:0px} -------------------------------------------------------------------------------- /dist/snippets/topbar-inside-titlebar.css: -------------------------------------------------------------------------------- 1 | .spotify__container--is-desktop.spotify__os--is-windows .main-topBar-container{padding-inline:60px 150px !important;padding-bottom:64px !important}.Root__top-container{grid-template-areas:"top-bar top-bar top-bar" "left-sidebar main-view right-sidebar" "now-playing-bar now-playing-bar now-playing-bar";grid-template-rows:auto 1fr auto}.Root__top-container .Root__top-bar{grid-area:top-bar;height:32px;margin-top:-32px}.Root__top-container .Root__top-bar .main-topBar-container{padding-inline:80px 0px;padding-bottom:64px;pointer-events:none}.Root__top-container .Root__top-bar .main-topBar-container .main-topBar-topbarContentWrapper>*:not(.main-topBar-searchBar){justify-content:center;display:flex}.Root__top-container .Root__top-bar .main-topBar-container .main-topBar-topbarContent{app-region:drag !important}.Root__top-container .Root__top-bar .main-topBar-container .main-topBar-topbarContent>*:not(.main-topBar-searchBar){justify-content:center;display:flex}.Root__top-container .Root__top-bar .main-topBar-container .main-topBar-topbarContent .main-entityHeader-topbarTitle{height:32px}.Root__top-container .Root__top-bar .main-topBar-container .main-topBar-background{display:none}.body-drag-top{height:48px}.main-view-container__scroll-node-child-spacer{height:15px}.playlist-playlist-playlist{margin-top:-15px !important}.profile-userOverview-container{margin-top:-15px !important}.artist-artistOverview-overview{margin-top:-15px !important}.album-albumPage-sectionWrapper{margin-top:-15px !important}.show-showPage-sectionWrapper{margin-top:-15px !important}.A4dupilHPIEDfhXDE0m0{margin-top:-15px !important}.uCHqQ74vvHOnctGg0X0B{margin-top:-15px !important}.lXcKpCtaEeFf1HifX139{margin-top:-15px !important}.MlK79hskRbFrN2OBjMkl{margin-top:-15px !important}.dpN5ViPOceUWNB5EQPHN{margin-top:-15px !important}.mmCZ5VczybT9VqKB5wFU{margin-top:-15px !important}.queue-queuePage-queuePage{margin-top:0 !important;top:0 !important}.search-searchCategory-SearchCategory{margin-top:0 !important;top:0 !important}.artist-artistDiscography-topBar{margin-top:0 !important;top:0 !important} -------------------------------------------------------------------------------- /dist/sort-plus/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/sort-plus/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /dist/spoqify-radios/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/spoqify-radios/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /dist/star-ratings-2/app.css: -------------------------------------------------------------------------------- 1 | /* extensions/star-ratings-2/assets/styles.css */ 2 | button.rating-1 svg { 3 | fill: #ed5564 !important; 4 | } 5 | button.rating-2 svg { 6 | fill: #ffce54 !important; 7 | } 8 | button.rating-3 svg { 9 | fill: #a0d568 !important; 10 | } 11 | button.rating-4 svg { 12 | fill: #4fc1e8 !important; 13 | } 14 | button.rating-5 svg { 15 | fill: #ac92eb !important; 16 | } 17 | -------------------------------------------------------------------------------- /dist/star-ratings-2/app.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": ["../../extensions/star-ratings-2/assets/styles.css"], 4 | "sourcesContent": ["button.rating-1 svg{fill:#ed5564 !important}button.rating-2 svg{fill:#ffce54 !important}button.rating-3 svg{fill:#a0d568 !important}button.rating-4 svg{fill:#4fc1e8 !important}button.rating-5 svg{fill:#ac92eb !important}"], 5 | "mappings": ";AAAA,MAAM,CAAC,SAAS;AAAI,QAAK;AAAkB;AAAC,MAAM,CAAC,SAAS;AAAI,QAAK;AAAkB;AAAC,MAAM,CAAC,SAAS;AAAI,QAAK;AAAkB;AAAC,MAAM,CAAC,SAAS;AAAI,QAAK;AAAkB;AAAC,MAAM,CAAC,SAAS;AAAI,QAAK;AAAkB;", 6 | "names": [] 7 | } 8 | -------------------------------------------------------------------------------- /dist/star-ratings-2/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/star-ratings-2/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /dist/star-ratings/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/star-ratings/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /dist/vaultify/prism.mjs: -------------------------------------------------------------------------------- 1 | fetch("https://api.github.com/repos/Delusoire/spicetify-extensions/contents/dist/vaultify/app.js") 2 | .then(res => res.json()) 3 | .then(json => atob(json.content)) 4 | .then(content => new Blob([content], { type: "application/javascript" })) 5 | .then(URL.createObjectURL) 6 | .then(url => import(url)) -------------------------------------------------------------------------------- /extensions/bad-lyrics/app.ts: -------------------------------------------------------------------------------- 1 | import { render } from "https://esm.sh/lit" 2 | 3 | import { PermanentMutationObserver } from "../../shared/util.ts" 4 | 5 | import { PlayerW } from "./utils/PlayerW.ts" 6 | import { LyricsWrapper } from "./components/components.ts" 7 | 8 | const injectLyrics = (insertSelector: string, scrollSelector: string) => () => { 9 | const lyricsContainer = document.querySelector(insertSelector) 10 | if (!lyricsContainer || lyricsContainer.classList.contains("injected")) return 11 | lyricsContainer.classList.add("injected") 12 | const lyricsContainerClone = lyricsContainer.cloneNode(false) as typeof lyricsContainer 13 | lyricsContainer.replaceWith(lyricsContainerClone) 14 | 15 | const ourLyricsContainer = new LyricsWrapper(scrollSelector) 16 | ourLyricsContainer.song = PlayerW.getSong() ?? null 17 | PlayerW.songSubject.subscribe(song => ourLyricsContainer.updateSong(song ?? null)) 18 | PlayerW.progressPercentSubject.subscribe(progress => ourLyricsContainer.updateProgress(progress)) 19 | render(ourLyricsContainer, lyricsContainerClone) 20 | } 21 | 22 | const injectNPVLyrics = injectLyrics( 23 | "aside .main-nowPlayingView-lyricsContent", 24 | "aside .main-nowPlayingView-lyricsContent", 25 | ) 26 | const injectCinemaLyrics = injectLyrics( 27 | "#lyrics-cinema .lyrics-lyrics-contentWrapper", 28 | "#lyrics-cinema .os-viewport-native-scrollbars-invisible", 29 | ) 30 | injectNPVLyrics() 31 | injectCinemaLyrics() 32 | new PermanentMutationObserver(".Root__right-sidebar", injectNPVLyrics) 33 | new PermanentMutationObserver(".Root__lyrics-cinema", injectCinemaLyrics) 34 | -------------------------------------------------------------------------------- /extensions/bad-lyrics/assets/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Bad lyrics, Beautfil Lyrics but worse. At least it doesn't take your cpu hostage? Credits; This extension started as a Beautiful Lyrics clone, and become worse over time. 3 | authors: 4 | - name: Delusoire 5 | url: https://github.com/Delusoire 6 | tags: 7 | - lyrics 8 | - bad 9 | --- 10 | -------------------------------------------------------------------------------- /extensions/bad-lyrics/assets/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/bad-lyrics/assets/preview.gif -------------------------------------------------------------------------------- /extensions/bad-lyrics/components/contexts.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "https://esm.sh/@lit/context" 2 | 3 | import { LyricsType } from "../utils/LyricsProvider.ts" 4 | 5 | export const scrollTimeoutCtx = createContext("scrollTimeout") 6 | export const scrollContainerCtx = createContext("scrollContainer") 7 | export const loadedLyricsTypeCtx = createContext("loadedLyricsType") 8 | -------------------------------------------------------------------------------- /extensions/bad-lyrics/components/mixins.ts: -------------------------------------------------------------------------------- 1 | import { consume } from "https://esm.sh/@lit/context" 2 | import { LitElement, html } from "https://esm.sh/lit" 3 | import { property, queryAssignedElements } from "https://esm.sh/lit/decorators.js" 4 | 5 | import { _ } from "../../../shared/deps.ts" 6 | 7 | import { scrollTimeoutCtx, scrollContainerCtx } from "./contexts.ts" 8 | 9 | type Constructor = new (...args: any[]) => T 10 | 11 | export declare class SyncedMixinI { 12 | content: string 13 | tsp: number 14 | tep: number 15 | 16 | updateProgress(scaledProgress: number, depthToActiveAncestor: number): void 17 | } 18 | 19 | export const SyncedMixin = >(superClass: T) => { 20 | class mixedClass extends superClass { 21 | @property() 22 | content = "" 23 | @property({ type: Number }) 24 | tsp = 0 // time start percent 25 | @property({ type: Number }) 26 | tep = 1 // time end percent 27 | 28 | updateProgress(scaledProgress: number, depthToActiveAncestor: number) {} 29 | } 30 | 31 | return mixedClass as Constructor & T 32 | } 33 | 34 | export const AnimatedMixin = >(superClass: T) => { 35 | class mixedClass extends superClass { 36 | csp!: number 37 | dtaa!: number 38 | updateProgress(scaledProgress: number, depthToActiveAncestor: number) { 39 | super.updateProgress(scaledProgress, depthToActiveAncestor) 40 | const clampedScaledProgress = _.clamp(scaledProgress, -0.5, 1.5) 41 | if (this.shouldAnimate(clampedScaledProgress, depthToActiveAncestor)) { 42 | this.csp = clampedScaledProgress 43 | this.dtaa = depthToActiveAncestor 44 | this.animateContent() 45 | } 46 | } 47 | shouldAnimate(clampedScaledProgress: number, depthToActiveAncestor: number) { 48 | return this.csp !== clampedScaledProgress || this.dtaa !== depthToActiveAncestor 49 | } 50 | animateContent() {} 51 | } 52 | 53 | return mixedClass 54 | } 55 | 56 | export const ScrolledMixin = >(superClass: T) => { 57 | class mixedClass extends superClass { 58 | @consume({ context: scrollTimeoutCtx, subscribe: true }) 59 | scrollTimeout = 0 60 | @consume({ context: scrollContainerCtx }) 61 | scrollContainer?: HTMLElement 62 | 63 | dtaa!: number 64 | 65 | updateProgress(progress: number, depthToActiveAncestor: number) { 66 | super.updateProgress(progress, depthToActiveAncestor) 67 | const isActive = depthToActiveAncestor === 0 68 | const wasActive = this.dtaa === 0 69 | const bypassProximityCheck = this.dtaa === undefined 70 | this.dtaa = depthToActiveAncestor 71 | 72 | if (!isActive || wasActive) return 73 | if (Date.now() < this.scrollTimeout || !this.scrollContainer) return 74 | 75 | const lineHeight = parseInt(document.defaultView!.getComputedStyle(this).lineHeight) 76 | const scrollTop = this.offsetTop - this.scrollContainer.offsetTop - lineHeight * 2 77 | const verticalLinesToActive = 78 | Math.abs(scrollTop - this.scrollContainer.scrollTop) / this.scrollContainer.offsetHeight 79 | 80 | if (!bypassProximityCheck && !_.inRange(verticalLinesToActive, 0.1, 0.75)) return 81 | 82 | this.scrollContainer.scrollTo({ 83 | top: scrollTop, 84 | behavior: document.visibilityState === "visible" ? "smooth" : "auto", 85 | }) 86 | } 87 | } 88 | 89 | return mixedClass 90 | } 91 | 92 | export const SyncedContainerMixin = >(superClass: T) => { 93 | class mixedClass extends superClass { 94 | @queryAssignedElements() 95 | childs!: NodeListOf 96 | 97 | computeChildProgress(rp: number, child: number) { 98 | return rp 99 | } 100 | 101 | updateProgress(rp: number, depthToActiveAncestor: number) { 102 | super.updateProgress(rp, depthToActiveAncestor) 103 | const childs = Array.from(this.childs) 104 | if (childs.length === 0) return 105 | 106 | childs.forEach((child, i) => { 107 | const progress = this.computeChildProgress(rp, i) 108 | const isActive = _.inRange(rp, child.tsp, child.tep) 109 | child.updateProgress(progress, depthToActiveAncestor + (isActive ? 0 : 1)) 110 | }) 111 | } 112 | 113 | render() { 114 | return html`
` 115 | } 116 | } 117 | 118 | return mixedClass 119 | } 120 | -------------------------------------------------------------------------------- /extensions/bad-lyrics/pkgs/spring.ts: -------------------------------------------------------------------------------- 1 | const TAU = Math.PI * 2 2 | 3 | const SLEEPING_EPSILON = 1e-7 4 | 5 | export class Spring { 6 | private W0: number 7 | private v: number 8 | 9 | private inEquilibrium = true 10 | 11 | private p_e: number 12 | 13 | // We allow consumers to specify their own timescales 14 | compute(time = Date.now()) { 15 | const current = this.inEquilibrium ? this.p : this.solve(time - this.lastUpdateTime) 16 | this.lastUpdateTime = time 17 | return current 18 | } 19 | 20 | constructor( 21 | private p: number, 22 | private dampingRatio: number, 23 | frequency: number, 24 | private lastUpdateTime = Date.now(), 25 | ) { 26 | if (dampingRatio * frequency < 0) { 27 | throw new Error("Spring does not converge.") 28 | } 29 | 30 | this.v = 0 31 | this.p_e = p 32 | this.W0 = frequency * TAU 33 | } 34 | 35 | private solve(dt: number): number { 36 | const offset = this.p - this.p_e 37 | const dp = this.v * dt 38 | const A = this.dampingRatio * this.W0 39 | const Adt = A * dt 40 | const decay = Math.exp(-Adt) 41 | 42 | let nextP, nextV 43 | 44 | if (this.dampingRatio == 1) { 45 | nextP = this.p_e + (offset * (1 + Adt) + dp) * decay 46 | nextV = (this.v * (1 - Adt) - offset * (A * Adt)) * decay 47 | } else if (this.dampingRatio < 1) { 48 | const W_W0 = Math.sqrt(1 - this.dampingRatio * this.dampingRatio) 49 | const W = this.W0 * W_W0 50 | 51 | const i = Math.cos(W * dt) 52 | const j = Math.sin(W * dt) 53 | 54 | nextP = this.p_e + (offset * i + (dp + Adt * offset) * (j / (W * dt))) * decay 55 | nextV = (this.v * (i - (A / W) * j) - offset * j * (this.W0 / W_W0)) * decay 56 | } else if (this.dampingRatio > 1) { 57 | const W_W0 = Math.sqrt(this.dampingRatio ** 2 - 1) 58 | const W = this.W0 * W_W0 59 | 60 | const r_average = -this.W0 * this.dampingRatio 61 | 62 | const r_1 = r_average + W 63 | const r_2 = r_average - W 64 | 65 | const c_2 = (offset * r_1 - this.v) / (r_1 - r_2) 66 | const c_1 = offset - c_2 67 | 68 | const e_1 = c_1 * Math.exp(r_1 * dt) 69 | const e_2 = c_2 * Math.exp(r_2 * dt) 70 | 71 | nextP = this.p_e + e_1 + e_2 72 | nextV = r_1 * e_1 + r_2 * e_2 73 | } else { 74 | throw "Solar flare detected." 75 | } 76 | 77 | if (Math.abs(nextV) > SLEEPING_EPSILON) { 78 | this.p = nextP 79 | this.v = nextV 80 | } else { 81 | this.reset(this.p_e) 82 | } 83 | 84 | return nextP 85 | } 86 | 87 | setEquilibrium(position: number) { 88 | if (this.p_e != position) { 89 | this.p_e = position 90 | this.inEquilibrium = false 91 | } 92 | return this.p_e 93 | } 94 | 95 | reset(position: number) { 96 | this.v = 0 97 | this.p = this.p_e = position 98 | this.inEquilibrium = true 99 | } 100 | 101 | isInEquilibrium = () => this.inEquilibrium 102 | } 103 | -------------------------------------------------------------------------------- /extensions/bad-lyrics/splines/catmullRomSpline.ts: -------------------------------------------------------------------------------- 1 | import { _ } from "../../../shared/deps.ts" 2 | import { TwoUplet, zip_n_uplets } from "../../../shared/fp.ts" 3 | import { remapScalar, scalarLerp, vector, vectorDist, vectorLerp } from "../../../shared/math.ts" 4 | 5 | export type vectorWithTime = readonly [number, vector] 6 | 7 | type Quadruplet = readonly [A, A, A, A] 8 | type PointQuadruplet = Quadruplet 9 | type TimeQuadruplet = Quadruplet 10 | export type PointInTimeQuadruplet = Quadruplet 11 | class CatmullRomCurve { 12 | private constructor(private P: PointQuadruplet, private T: TimeQuadruplet) {} 13 | 14 | static fromPointsAndAlpha(P: PointQuadruplet, alpha: number) { 15 | const T = zip_n_uplets>(2)(P as unknown as vector[]) 16 | .map(([Pi, Pj]) => vectorDist(Pi, Pj) ** alpha) 17 | .map((ki, i, kis) => (i > 0 ? kis[i - 1] : 0) + ki) as unknown as TimeQuadruplet 18 | return new CatmullRomCurve(P, T) 19 | } 20 | 21 | static fromPointsInTime(points: PointInTimeQuadruplet) { 22 | const [T, P] = _.unzip(points) as unknown as [TimeQuadruplet, PointQuadruplet] 23 | return new CatmullRomCurve(P, T) 24 | } 25 | 26 | at(t: number) { 27 | t = _.clamp(t, this.T[1], this.T[2]) 28 | const vectorLerpWithRemapedScalar = (s: vectorWithTime, e: vectorWithTime, x: number) => 29 | vectorLerp(s[1], e[1], remapScalar(s[0], e[0], x)) 30 | 31 | const A = [ 32 | vectorLerpWithRemapedScalar([this.T[0], this.P[0]], [this.T[1], this.P[1]], t), 33 | vectorLerpWithRemapedScalar([this.T[1], this.P[1]], [this.T[2], this.P[2]], t), 34 | vectorLerpWithRemapedScalar([this.T[2], this.P[2]], [this.T[3], this.P[3]], t), 35 | ] 36 | const B = [ 37 | vectorLerpWithRemapedScalar([this.T[0], A[0]], [this.T[2], A[1]], t), 38 | vectorLerpWithRemapedScalar([this.T[1], A[1]], [this.T[3], A[2]], t), 39 | ] 40 | return vectorLerpWithRemapedScalar([this.T[1], B[0]], [this.T[2], B[1]], t) 41 | } 42 | } 43 | 44 | export class AlphaCatmullRomSpline { 45 | private catnumRollCurves 46 | 47 | private constructor(private points: Array, alpha: number) { 48 | this.catnumRollCurves = zip_n_uplets>(4)(points).map(P => 49 | CatmullRomCurve.fromPointsAndAlpha(P as unknown as PointQuadruplet, alpha), 50 | ) 51 | } 52 | 53 | at(t: number) { 54 | const i = Math.floor(t) 55 | return this.catnumRollCurves[i].at(t - i) 56 | } 57 | 58 | static fromPoints(points: Array, alpha = 0.5) { 59 | if (points.length < 4) return null 60 | 61 | return new AlphaCatmullRomSpline(points, alpha) 62 | } 63 | 64 | static fromPointsClamped(points: Array, alpha = 0.5) { 65 | if (points.length < 2) return null 66 | 67 | const [P1, P2] = _.take(points, 2) 68 | const [P3, P4] = _.takeRight(points, 2) 69 | const P0 = vectorLerp(P1, P2, -1) 70 | const P5 = vectorLerp(P3, P4, 2) 71 | 72 | return this.fromPoints([P0, ...points, P5], alpha) 73 | } 74 | } 75 | 76 | export class CatmullRomSpline { 77 | private points 78 | private catnumRollCurves 79 | 80 | private constructor(points: Array) { 81 | this.points = _.sortBy(points, p => p[0]) 82 | this.catnumRollCurves = zip_n_uplets>(4)(this.points).map(P => 83 | CatmullRomCurve.fromPointsInTime(P as unknown as PointInTimeQuadruplet), 84 | ) 85 | } 86 | 87 | at(t: number) { 88 | const point = [t, []] as vectorWithTime 89 | const i = _.clamp(_.sortedLastIndexBy(this.points, point, p => p[0]) - 2, 0, this.catnumRollCurves.length - 1) 90 | return this.catnumRollCurves[i].at(t) 91 | } 92 | 93 | static fromPoints(points: Array) { 94 | if (points.length < 4) return null 95 | 96 | return new CatmullRomSpline(points) 97 | } 98 | 99 | static fromPointsClamped(points: Array) { 100 | if (points.length < 2) return null 101 | 102 | const [P1, P2] = _.take(points, 2) 103 | const [P3, P4] = _.takeRight(points, 2) 104 | const P0 = [scalarLerp(P1[0], P2[0], -1), vectorLerp(P1[1], P2[1], -1)] as const 105 | const P5 = [scalarLerp(P3[0], P4[0], 2), vectorLerp(P3[1], P4[1], 2)] as const 106 | 107 | return this.fromPoints([P0, ...points, P5]) 108 | } 109 | } 110 | 111 | function deCasteljau(points: vector[], position: number) { 112 | if (points.length < 2) return points[0] 113 | return deCasteljau( 114 | zip_n_uplets>(2)(points).map(([Pi, Pj]) => vectorLerp(Pi, Pj, position)), 115 | position, 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /extensions/bad-lyrics/splines/monotoneNormalSpline.ts: -------------------------------------------------------------------------------- 1 | import { MonotoneCubicHermitInterpolation } from "https://esm.sh/v135/@adaskothebeast/splines@4.0.0/es2022/splines.mjs" 2 | 3 | import { _ } from "../../../shared/deps.ts" 4 | 5 | export class MonotoneNormalSpline extends MonotoneCubicHermitInterpolation { 6 | at(t: number) { 7 | const t0 = this.xs[0], 8 | tf = this.xs.at(-1)! 9 | const ct = _.clamp(t, t0, tf) 10 | return super.interpolate(ct) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /extensions/bad-lyrics/splines/splines.ts: -------------------------------------------------------------------------------- 1 | import { _ } from "../../../shared/deps.ts" 2 | import { TwoUplet, Triplet, zip_n_uplets } from "../../../shared/fp.ts" 3 | import { 4 | matrix, 5 | matrixMultMatrix, 6 | remapScalar, 7 | scalarAddVector, 8 | scalarMultVector, 9 | vector, 10 | vectorAddVector, 11 | vectorDist, 12 | vectorDivScalar, 13 | vectorMultVector, 14 | vectorSubVector, 15 | } from "../../../shared/math.ts" 16 | 17 | enum EndCondition { 18 | NATURAL, 19 | CLOSED, 20 | } 21 | type EndConditionSideable = EndCondition.NATURAL | vector 22 | type EndConditions = TwoUplet | EndCondition.CLOSED 23 | 24 | class Monomial { 25 | constructor(private segments: matrix[], private grid = _.range(segments.length + 1)) {} 26 | 27 | at(t: number, n = 0) { 28 | t = _.clamp(t, this.grid[0], this.grid.at(-1)! - 1e-7) 29 | const i = _.sortedLastIndex(this.grid, t) - 1 30 | const [t0, t1] = this.grid.slice(i, i + 2) 31 | t = remapScalar(t0, t1, t) 32 | const coefficients = this.segments[i].slice(0, -n || undefined) 33 | const powers = _.range(coefficients.length).reverse() 34 | const weights = vectorDivScalar( 35 | _.range(n) 36 | .map(i => scalarAddVector(i + 1, powers)) 37 | .reduce((u, v) => u.map((_, i) => u[i] * v[i]), new Array(powers.length).fill(1)), 38 | (t1 - t0) ** n, 39 | ) 40 | const tps = powers.map(power => t ** power) 41 | return matrixMultMatrix([vectorMultVector(tps, weights)], coefficients)[0] 42 | } 43 | } 44 | 45 | class CubicHermite extends Monomial { 46 | static matrix = [ 47 | [2, -2, 1, 1], 48 | [-3, 3, -2, -1], 49 | [0, 0, 1, 0], 50 | [1, 0, 0, 0], 51 | ] 52 | 53 | constructor(vertices: vector[], tangents: vector[], grid = _.range(vertices.length)) { 54 | if (vertices.length < 2) throw "At least 2 vertices are needed" 55 | if (tangents.length !== 2 * (vertices.length - 1)) throw "Exactly 2 tangents per segment needed" 56 | if (vertices.length !== grid.length) throw "As many grid items as vertices are needed" 57 | 58 | const zip_vertices = zip_n_uplets>(2)(vertices) 59 | const zip_grid = zip_n_uplets>(2)(grid) 60 | 61 | const segments = _.zip(zip_vertices, zip_grid).map(([[x0, x1], [t0, t1]], i) => { 62 | const [v0, v1] = tangents.slice(i * 2, i * 2 + 2) 63 | const control_values = [x0, x1, scalarMultVector(t1 - t0, v0), scalarMultVector(t1 - t0, v1)] 64 | return matrixMultMatrix(CubicHermite.matrix, control_values) 65 | }) 66 | 67 | super(segments, grid) 68 | } 69 | } 70 | 71 | export class KochanekBartels extends CubicHermite { 72 | static _calculate_tangents(points: vector[], times: Triplet, tcb: Triplet) { 73 | const [x_1, x0, x1] = points 74 | const [t_1, t0, t1] = times 75 | const [T, C, B] = tcb 76 | const a = (1 - T) * (1 + C) * (1 + B) 77 | const b = (1 - T) * (1 - C) * (1 - B) 78 | const c = (1 - T) * (1 - C) * (1 + B) 79 | const d = (1 - T) * (1 + C) * (1 - B) 80 | const delta_1 = t0 - t_1 81 | const delta0 = t1 - t0 82 | const v_1 = vectorDivScalar(vectorSubVector(x0, x_1), delta_1) 83 | const v0 = vectorDivScalar(vectorSubVector(x1, x0), delta0) 84 | const incoming = vectorDivScalar( 85 | vectorAddVector(scalarMultVector(c * delta0, v_1), scalarMultVector(d * delta_1, v0)), 86 | delta_1 + delta0, 87 | ) 88 | const outgoing = vectorDivScalar( 89 | vectorAddVector(scalarMultVector(a * delta0, v_1), scalarMultVector(b * delta_1, v0)), 90 | delta_1 + delta0, 91 | ) 92 | return [incoming, outgoing] 93 | } 94 | 95 | static fromAlpha( 96 | vertices: vector[], 97 | tcb: Triplet, 98 | alpha = 0, 99 | endconditions: EndConditions = [EndCondition.NATURAL, EndCondition.NATURAL], 100 | ) { 101 | const deltas = zip_n_uplets>(2)(vertices).map(([x0, x1]) => vectorDist(x0, x1) ** alpha) 102 | const grid = deltas.reduce((partialSums, delta) => [...partialSums, partialSums.at(-1)! + delta], [0]) 103 | return KochanekBartels.fromGrid(vertices, tcb, grid, endconditions) 104 | } 105 | 106 | static fromGrid( 107 | vertices: vector[], 108 | tcb: Triplet, 109 | grid: number[], 110 | endconditions: EndConditions = [EndCondition.NATURAL, EndCondition.NATURAL], 111 | ) { 112 | const closed = endconditions === EndCondition.CLOSED 113 | const tcb_slots = vertices.length - (closed ? 0 : 2) 114 | return new KochanekBartels(vertices, new Array(tcb_slots).fill(tcb), grid, endconditions) 115 | } 116 | 117 | private constructor(vertices: vector[], tcb: Array>, grid: number[], endconditions: EndConditions) { 118 | if (vertices.length < 2) throw "At least two vertices are required" 119 | if (vertices.length !== grid.length) throw "Number of grid values must be same as vertices" 120 | 121 | const closed = endconditions === EndCondition.CLOSED 122 | if (closed) { 123 | vertices.push(vertices[0], vertices[1]) 124 | tcb = [...tcb.slice(1), tcb[0]] 125 | const first_interval = grid[1] - grid[0] 126 | grid.push(grid.at(-1)! + first_interval) 127 | } 128 | const zip_vertices = zip_n_uplets>(3)(vertices) 129 | const zip_grid = zip_n_uplets>(3)(grid) 130 | 131 | let tangents = _.zip(zip_vertices, zip_grid, tcb).flatMap(([points, times, tcb]) => 132 | KochanekBartels._calculate_tangents(points!, times!, tcb!), 133 | ) 134 | 135 | if (closed) { 136 | tangents = [tangents.at(-1)!, ...tangents.slice(0, -1)] 137 | } else if (!tangents.length) { 138 | // simple line between two points 139 | const tangent = scalarMultVector(grid[1] - grid[0], vectorSubVector(vertices[1], vertices[0])) 140 | tangents = [tangent, tangent] 141 | } else { 142 | const [start, end] = endconditions 143 | tangents = [ 144 | _end_tangent(start, vertices.slice(0, 2), grid.slice(0, 2), tangents[0]), 145 | ...tangents, 146 | _end_tangent(end, vertices.slice(-2), grid.slice(-2), tangents.at(-1)!), 147 | ] 148 | } 149 | 150 | super(vertices, tangents, grid) 151 | } 152 | } 153 | 154 | function _end_tangent( 155 | condition: EndCondition.NATURAL | vector, 156 | vertices: vector[], 157 | times: number[], 158 | other_tangent: vector, 159 | ) { 160 | return condition === EndCondition.NATURAL ? _natural_tangent(vertices, times, other_tangent) : condition 161 | } 162 | 163 | function _natural_tangent(vertices: vector[], times: number[], tangent: vector) { 164 | const [x0, x1] = vertices 165 | const [t0, t1] = times 166 | const delta = t1 - t0 167 | return vectorSubVector(scalarMultVector(3 / (2 * delta), vectorSubVector(x1, x0)), vectorDivScalar(tangent, 2)) 168 | } 169 | -------------------------------------------------------------------------------- /extensions/bad-lyrics/utils/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/bad-lyrics/utils/.DS_Store -------------------------------------------------------------------------------- /extensions/bad-lyrics/utils/PlayerW.ts: -------------------------------------------------------------------------------- 1 | import { Subject, animationFrameScheduler, asyncScheduler } from "https://esm.sh/rxjs" 2 | 3 | import { onPlayedPaused, onSongChanged } from "../../../shared/listeners.ts" 4 | 5 | import { Song } from "./Song.ts" 6 | 7 | const { PlayerAPI } = Spicetify.Platform 8 | 9 | export const PlayerW = new (class { 10 | private Song?: Song 11 | isPaused = PlayerAPI._state.isPaused 12 | progressPercent = 0 13 | 14 | songSubject = new Subject() 15 | isPausedSubject = new Subject() 16 | progressPercentSubject = new Subject() 17 | 18 | getSong = () => this.Song 19 | 20 | constructor() { 21 | onSongChanged(state => { 22 | const { item } = state 23 | 24 | if (item && item.type === "track") { 25 | const uri = item.uri 26 | const name = item.name 27 | const artist = item.metadata.artist_name 28 | const album = item.album.name 29 | const duration = item.duration.milliseconds 30 | const isPaused = state.isPaused 31 | const metadata = item.metadata 32 | this.Song = new Song({ uri, name, artist, album, duration, isPaused, metadata }) 33 | } else { 34 | this.Song = undefined 35 | } 36 | 37 | this.songSubject.next(this.Song) 38 | }) 39 | 40 | onPlayedPaused(state => { 41 | const isPausedNext = state.isPaused ?? true 42 | if (this.isPaused !== isPausedNext) { 43 | if (!isPausedNext) { 44 | this.startTimestepping() 45 | } 46 | this.isPaused = !this.isPaused 47 | this.isPausedSubject.next(this.isPaused) 48 | } 49 | }) 50 | } 51 | 52 | private triggerTimestampSync() { 53 | let autoSyncs = 0 54 | 55 | const timeoutFn = () => 1000 * autoSyncs++ 56 | 57 | asyncScheduler.schedule( 58 | function (self) { 59 | if (self!.isPaused) return 60 | 61 | if (!PlayerAPI._events.emitResumeSync()) { 62 | PlayerAPI._contextPlayer.resume({}) 63 | } 64 | 65 | this.schedule(self, timeoutFn()) 66 | }, 67 | timeoutFn(), 68 | this, 69 | ) 70 | } 71 | 72 | private tryUpdateScaledProgress(scaledProgress: number) { 73 | if (this.progressPercent === scaledProgress) return 74 | this.progressPercent = scaledProgress 75 | this.progressPercentSubject.next(scaledProgress) 76 | } 77 | 78 | private startTimestepping() { 79 | animationFrameScheduler.schedule( 80 | function (self) { 81 | if (self!.isPaused) return 82 | self!.tryUpdateScaledProgress(Spicetify.Player.getProgressPercent()) 83 | this.schedule(self) 84 | }, 85 | undefined, 86 | this, 87 | ) 88 | 89 | this.triggerTimestampSync() 90 | } 91 | 92 | setTimestamp = (timestamp: number) => { 93 | Spicetify.Player.seek(timestamp) // ms or percent 94 | this.tryUpdateScaledProgress(timestamp) 95 | } 96 | })() 97 | -------------------------------------------------------------------------------- /extensions/bad-lyrics/utils/Song.ts: -------------------------------------------------------------------------------- 1 | import { Track } from "https://esm.sh/v135/@fostertheweb/spotify-web-api-ts-sdk@1.2.1/dist/mjs/types.js" 2 | import { Lyrics, findLyrics } from "./LyricsProvider.ts" 3 | 4 | export type SpotifyTrackInformation = Track 5 | 6 | export class Song { 7 | readonly uri: string 8 | readonly name: string 9 | readonly artist: string 10 | readonly album: string 11 | readonly duration: number 12 | readonly isLocal: boolean 13 | readonly lyrics: Promise 14 | 15 | isPaused: boolean 16 | constructor(opts: { 17 | uri: string 18 | name: string 19 | artist: string 20 | album: string 21 | duration: number 22 | isPaused: boolean 23 | metadata: Spicetify.Platform.PlayerAPI.TrackMetadata 24 | }) { 25 | this.uri = opts.uri 26 | this.name = opts.name 27 | this.artist = opts.artist 28 | this.album = opts.album 29 | this.duration = opts.duration 30 | this.isLocal = opts.metadata.is_local === "true" 31 | this.isPaused = opts.isPaused 32 | 33 | this.lyrics = findLyrics({ 34 | uri: this.uri, 35 | title: this.name, 36 | artist: this.artist, 37 | album: this.album, 38 | durationS: this.duration / 1000, 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /extensions/corbeille/app.ts: -------------------------------------------------------------------------------- 1 | import { fetchFolder, fetchPlaylistContents } from "../../shared/platformApi.ts" 2 | import { onSongChanged } from "../../shared/listeners.ts" 3 | 4 | import { CONFIG } from "./settings.ts" 5 | 6 | export const trashedTrackUris = [] as string[] 7 | global.trashedTrackUris = trashedTrackUris 8 | 9 | const loadTrash = async () => { 10 | const trashFolder = await fetchFolder(CONFIG.trashFolderUri) 11 | 12 | const playlistUris = trashFolder.items 13 | .map(p => [p.uri, Number(p.name)] as const) 14 | .reduce((uris, [uri, rating]) => { 15 | uris[rating] = uri 16 | return uris 17 | }, [] as string[]) 18 | 19 | const playlists = await Promise.all(playlistUris.map(fetchPlaylistContents)) 20 | trashedTrackUris.concat(playlists.flatMap(tracks => tracks.map(t => t.uri))) 21 | } 22 | 23 | loadTrash() 24 | 25 | onSongChanged(state => { 26 | trashedTrackUris.includes(state.item.uri) && Spicetify.Platform.PlayerAPI.skipToNext() 27 | }) 28 | -------------------------------------------------------------------------------- /extensions/corbeille/assets/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/corbeille/assets/README.md -------------------------------------------------------------------------------- /extensions/corbeille/settings.ts: -------------------------------------------------------------------------------- 1 | import { createFolder } from "../../shared/platformApi.ts" 2 | import { SettingsSection } from "../../shared/settings.tsx" 3 | import { SpotifyURI } from "../../shared/util.ts" 4 | 5 | const TRASH_FOLDER_NAME = "🗑️ Trash" 6 | 7 | const settings = new SettingsSection("Sort Plus").addInput( 8 | { 9 | id: "trashFolderUri", 10 | desc: "Trash folder uri", 11 | inputType: "text", 12 | }, 13 | async () => (await createFolder(TRASH_FOLDER_NAME)).uri, 14 | ) 15 | 16 | settings.pushSettings() 17 | 18 | export const CONFIG = settings.toObject() as { 19 | trashFolderUri: SpotifyURI 20 | } 21 | -------------------------------------------------------------------------------- /extensions/detect-duplicates/app.ts: -------------------------------------------------------------------------------- 1 | import { onTrackListMutationListeners } from "../../shared/listeners.ts" 2 | 3 | import Dexie, { Table } from "https://esm.sh/dexie" 4 | 5 | interface IsrcObject { 6 | isrc: string 7 | uri: string 8 | } 9 | 10 | const db = new (class extends Dexie { 11 | webTracks!: Table 12 | isrcs!: Table 13 | 14 | constructor() { 15 | super("library-data") 16 | this.version(1).stores({ 17 | webTracks: "&uri", 18 | isrcs: "&isrc, uri", 19 | }) 20 | } 21 | })() 22 | 23 | import { searchTracks } from "../../shared/GraphQL/searchTracks.ts" 24 | import { spotifyApi } from "../../shared/api.ts" 25 | import { chunkify50 } from "../../shared/fp.ts" 26 | import { Track } from "https://esm.sh/v135/@fostertheweb/spotify-web-api-ts-sdk@1.2.1/dist/mjs/types.js" 27 | import { _ } from "../../shared/deps.ts" 28 | 29 | const { URI } = Spicetify 30 | 31 | export const getMainUrisForIsrcs = async (isrcs: string[]) => { 32 | const tracks = await db.isrcs.bulkGet(isrcs) 33 | const missedTracks = tracks.reduce((missed, track, i) => { 34 | track || missed.push(i) 35 | return missed 36 | }, [] as number[]) 37 | 38 | if (missedTracks.length) { 39 | const missedIsrcs = missedTracks.map(i => isrcs[i]) 40 | const resultsIsrcs = await Promise.allSettled(missedIsrcs.map(isrc => searchTracks(`isrc:${isrc}`, 0, 1))) 41 | const filledTracks = _.compact( 42 | resultsIsrcs.map((resultsIsrc, i) => { 43 | const isrc = isrcs[i] 44 | if (resultsIsrc.status === "fulfilled") { 45 | const uri = resultsIsrc.value[0]?.item.data.uri 46 | if (!uri) { 47 | console.error("Couldn't get matching track for isrc:", isrc) 48 | return 49 | } 50 | return { isrc, uri } 51 | } 52 | console.error("Failed searching track for isrc:", isrc) 53 | }), 54 | ) 55 | db.isrcs.bulkAdd(filledTracks) 56 | missedTracks.forEach((missedTrack, i) => { 57 | tracks[missedTrack] = filledTracks[i] 58 | }) 59 | } 60 | 61 | return tracks.map(track => track?.uri) 62 | } 63 | 64 | export const getISRCsForUris = async (uris: string[]) => { 65 | const tracks = await db.webTracks.bulkGet(uris) 66 | const missedTracks = tracks.reduce((missed, track, i) => { 67 | track || missed.push(i) 68 | return missed 69 | }, [] as number[]) 70 | 71 | if (missedTracks.length) { 72 | const missedIds = _.compact(missedTracks.map(i => URI.fromString(uris[i]).id)) 73 | const filledTracks = await chunkify50(is => spotifyApi.tracks.get(is))(missedIds) 74 | db.webTracks.bulkAdd(filledTracks) 75 | missedTracks.forEach((missedTrack, i) => { 76 | tracks[missedTrack] = filledTracks[i] 77 | }) 78 | } 79 | 80 | return tracks.map(track => (track as Track).external_ids.isrc) 81 | } 82 | 83 | const greyOutTrack = (track: HTMLDivElement) => { 84 | track.style.backgroundColor = "gray" 85 | track.style.opacity = "0.3" 86 | } 87 | 88 | onTrackListMutationListeners.push(async (_, tracks) => { 89 | const uris = tracks.map(track => track.props.uri) 90 | const isrcs = await getISRCsForUris(uris) 91 | const isrcUris = await getMainUrisForIsrcs(isrcs) 92 | tracks.map((track, i) => { 93 | const isrcUri = isrcUris[i] 94 | if (isrcUri && uris[i] !== isrcUri) { 95 | greyOutTrack(track) 96 | } 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /extensions/detect-duplicates/assets/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Placeholder 3 | authors: 4 | - name: Delusoire 5 | url: https://github.com/Delusoire 6 | tags: 7 | - isrc 8 | --- 9 | -------------------------------------------------------------------------------- /extensions/keyboard-shortcuts/app.ts: -------------------------------------------------------------------------------- 1 | import { toggleTracksLiked } from "../../shared/platformApi.ts" 2 | 3 | import { KEY_LIST, _SneakOverlay, mousetrapInst } from "./sneak.ts" 4 | import { Bind, appScroll, appScrollY, openPage, rotateSidebar } from "./util.ts" 5 | 6 | const { Keyboard } = Spicetify 7 | const { UserAPI, UpdateAPI, History, PlayerAPI } = Spicetify.Platform 8 | 9 | let sneakOverlay: _SneakOverlay 10 | 11 | const binds = [ 12 | new Bind("s", () => { 13 | sneakOverlay = document.createElement("sneak-overlay") 14 | document.body.append(sneakOverlay) 15 | }), 16 | new Bind("s", async () => { 17 | await UserAPI._product_state_service.putValues({ pairs: { "app-developer": "2" } }) 18 | UpdateAPI.applyUpdate() 19 | }).setShift(true), 20 | new Bind("tab", () => rotateSidebar(1)), 21 | new Bind("tab", () => rotateSidebar(-1)).setShift(true), 22 | new Bind("h", History.goBack).setShift(true), 23 | new Bind("l", History.goForward).setShift(true), 24 | new Bind("j", () => appScroll(1)), 25 | new Bind("k", () => appScroll(-1)), 26 | new Bind("g", () => appScrollY(0)), 27 | new Bind("g", () => appScrollY(Number.MAX_SAFE_INTEGER)).setShift(true), 28 | new Bind("m", () => PlayerAPI._state.item?.uri && toggleTracksLiked([PlayerAPI._state.item?.uri])), 29 | new Bind("/", e => { 30 | e.preventDefault() 31 | openPage("/search") 32 | }), 33 | ] 34 | 35 | binds.map(bind => bind.register()) 36 | 37 | mousetrapInst.bind(KEY_LIST, (e: KeyboardEvent) => sneakOverlay?.updateProps(e.key), "keypress") 38 | mousetrapInst.bind(Keyboard.KEYS.ESCAPE, () => sneakOverlay?.remove()) 39 | -------------------------------------------------------------------------------- /extensions/keyboard-shortcuts/assets/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Vim-like keyboard shortcuts for spotify. Now with sneak mode. 3 | authors: 4 | - name: Delusoire 5 | url: https://github.com/Delusoire 6 | - name: khanhas 7 | - name: Tetrax-10 8 | tags: 9 | - keybind 10 | - keyboard 11 | - sneak 12 | - vim 13 | --- 14 | 15 | The default keybinds are as follows: 16 | 17 | - `s`: Enter 'sneak' mode, which is similar to vim-sneak. 18 | - `shift + s`: Reload Spotify with developer mode enabled. 19 | - `tab`: Go to the next page from the navigation bar. 20 | - `shift + tab`: Go to the previous page from the navigation bar. 21 | - `shift + h`: Go to the previous page from the history. 22 | - `shift + l`: Go to the next page from the history. 23 | - `j`: Scroll down. 24 | - `k`: Scroll up. 25 | - `g`: Scroll to the top. 26 | - `shift + g`: Scroll to the bottom. 27 | - `m`: Like/unlike the current playing song. 28 | - `/`: Shortcut for searching. 29 | -------------------------------------------------------------------------------- /extensions/keyboard-shortcuts/assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/keyboard-shortcuts/assets/preview.png -------------------------------------------------------------------------------- /extensions/keyboard-shortcuts/sneak.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, css, html } from "https://esm.sh/lit" 2 | import { customElement, property } from "https://esm.sh/lit/decorators.js" 3 | import { map } from "https://esm.sh/lit/directives/map.js" 4 | import { styleMap } from "https://esm.sh/lit/directives/style-map.js" 5 | 6 | import { CLICKABLE_ELEMENT_SELECTOR, isElementInViewPort } from "./util.ts" 7 | 8 | export const mousetrapInst = Spicetify.Mousetrap() 9 | export const KEY_LIST = "abcdefghijklmnopqrstuvwxyz".split("") 10 | 11 | export let listeningToSneakBinds = false 12 | 13 | @customElement("sneak-key") 14 | class _SneakKey extends LitElement { 15 | static styles = css` 16 | :host > span { 17 | position: fixed; 18 | padding: 3px 6px; 19 | background-color: black; 20 | border-radius: 3px; 21 | border: solid 2px white; 22 | color: white; 23 | text-transform: lowercase; 24 | line-height: normal; 25 | font-size: 14px; 26 | font-weight: 500; 27 | } 28 | ` 29 | 30 | @property() 31 | key = "None" 32 | 33 | @property() 34 | target = document.body 35 | 36 | protected render() { 37 | const { x, y } = this.target.getBoundingClientRect() 38 | const styles = { 39 | top: y + "px", 40 | left: x + "px", 41 | } 42 | return html`${this.key}` 43 | } 44 | } 45 | 46 | @customElement("sneak-overlay") 47 | export class _SneakOverlay extends LitElement { 48 | static styles = css` 49 | :host { 50 | z-index: 1e5; 51 | position: absolute; 52 | width: 100%; 53 | height: 100%; 54 | display: block; 55 | } 56 | ` 57 | 58 | @property() 59 | props = [] as Array<{ key: string; target: HTMLElement }> 60 | 61 | constructor() { 62 | super() 63 | 64 | requestAnimationFrame(() => { 65 | let k1 = 0, 66 | k2 = 0 67 | 68 | this.props = Array.from(document.querySelectorAll(CLICKABLE_ELEMENT_SELECTOR)) 69 | // .filter(isElementVisible), 70 | .filter(isElementInViewPort) 71 | .map(target => { 72 | const key = KEY_LIST[k1] + KEY_LIST[k2++] 73 | if (k2 >= KEY_LIST.length) k1++, (k2 = 0) 74 | return { target, key } 75 | }) 76 | 77 | if (k1 + k2 === 0) this.remove() 78 | else listeningToSneakBinds = true 79 | }) 80 | } 81 | 82 | disconnectedCallback() { 83 | super.disconnectedCallback() 84 | listeningToSneakBinds = false 85 | } 86 | 87 | updateProps(key: KeyboardEvent["key"]) { 88 | if (!listeningToSneakBinds) return 89 | 90 | this.props = this.props.filter(prop => { 91 | const [k1, ...ks] = prop.key.toLowerCase() 92 | if (k1 !== key) return false 93 | prop.key = ks.join("") 94 | return true 95 | }) 96 | if (this.props.length === 1) this.props[0].target.click() 97 | if (this.props.length < 2) this.remove() 98 | } 99 | 100 | protected render() { 101 | return html`${map(this.props, i => html``)}` 102 | } 103 | } 104 | 105 | declare global { 106 | interface HTMLElementTagNameMap { 107 | "sneak-key": _SneakKey 108 | "sneak-overlay": _SneakOverlay 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /extensions/keyboard-shortcuts/util.ts: -------------------------------------------------------------------------------- 1 | import { _ } from "../../shared/deps.ts" 2 | 3 | import { listeningToSneakBinds } from "./sneak.ts" 4 | 5 | const { Keyboard } = Spicetify 6 | const { History } = Spicetify.Platform 7 | 8 | const SCROLL_STEP = 25 9 | 10 | const getApp = () => document.querySelector(".Root__main-view div.os-viewport") 11 | 12 | export const appScroll = (s: number) => { 13 | const app = getApp() 14 | if (!app) return 15 | const scrollIntervalId = setInterval(() => { 16 | app.scrollTop += s * SCROLL_STEP 17 | }, 10) 18 | document.addEventListener("keyup", () => clearInterval(scrollIntervalId)) 19 | } 20 | 21 | export const appScrollY = (y: number) => getApp()?.scroll(0, y) 22 | 23 | export const openPage = (page: string) => History.push({ pathname: page }) 24 | 25 | export const rotateSidebar = (offset: number) => { 26 | if (offset === 0) return 27 | 28 | const navLinks = Array.from( 29 | Array.from(document.querySelectorAll(".main-yourLibraryX-navLink")).values(), 30 | ) 31 | 32 | if (navLinks.length === 0) return 33 | 34 | const activeNavLink = document.querySelector(".main-yourLibraryX-navLinkActive") 35 | let activeNavLinkIndex = navLinks.findIndex(e => e === activeNavLink) 36 | if (activeNavLinkIndex === -1 && offset < 0) activeNavLinkIndex = navLinks.length 37 | let target = activeNavLinkIndex + (offset % navLinks.length) 38 | if (target < 0) target += navLinks.length 39 | navLinks[target].click() 40 | } 41 | 42 | export const resizeLeftSidebar = (pxs: number) => { 43 | const html = document.firstElementChild as HTMLHtmlElement 44 | const htmlStyle = html.style 45 | htmlStyle.cssText = htmlStyle.cssText.replace(/(--left-sidebar-width: )[^;]+/, `$1${pxs}px`) 46 | } 47 | 48 | export class Bind { 49 | private ctrl = false 50 | private shift = false 51 | private alt = false 52 | constructor(private key: string, private callback: (event: KeyboardEvent) => void) {} 53 | 54 | setCtrl = (required: boolean) => { 55 | this.ctrl = required 56 | return this 57 | } 58 | setShift = (required: boolean) => { 59 | this.shift = required 60 | return this 61 | } 62 | setAlt = (required: boolean) => { 63 | this.alt = required 64 | return this 65 | } 66 | 67 | register = () => 68 | Keyboard.registerShortcut( 69 | { key: this.key, ctrl: this.ctrl, shift: this.shift, alt: this.alt }, 70 | event => void (!listeningToSneakBinds && this.callback(event)), 71 | ) 72 | } 73 | 74 | export const isElementVisible = (e: HTMLElement) => e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) 75 | export const isElementInViewPort = (e: HTMLElement) => { 76 | const c = document.body 77 | const bound = e.getBoundingClientRect() 78 | const within = (m: number, M: number) => (x: number) => m <= x && x <= M 79 | const f = (top: number) => _.flow(_.mean, within(0, top)) 80 | return f(c.clientHeight)([bound.top, bound.bottom]) && f(c.clientWidth)([bound.left, bound.right]) 81 | } 82 | 83 | export const CLICKABLE_ELEMENT_SELECTOR = `.Root__top-container [href]:not(link),.Root__top-container button,.Root__top-container [role="button"]` 84 | -------------------------------------------------------------------------------- /extensions/play-enhanced-songs/app.ts: -------------------------------------------------------------------------------- 1 | import { fetchPlaylistEnhancedSongs } from "../../shared/platformApi.ts" 2 | import { SpotifyURI } from "../../shared/util.ts" 3 | 4 | const { URI, ContextMenu } = Spicetify 5 | const { PlayerAPI } = Spicetify.Platform 6 | 7 | const playEnhancedSongs = async (uri: SpotifyURI) => { 8 | const queue = await fetchPlaylistEnhancedSongs(uri) 9 | PlayerAPI.clearQueue() 10 | PlayerAPI.addToQueue(queue) 11 | } 12 | 13 | // Menu 14 | 15 | new ContextMenu.Item( 16 | "Play enhanced songs", 17 | ([uri]) => playEnhancedSongs(uri), 18 | ([uri]) => URI.isPlaylistV1OrV2(uri), 19 | "enhance", 20 | ).register() 21 | -------------------------------------------------------------------------------- /extensions/play-enhanced-songs/assets/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Play your playlists and others' with enhanced songs 3 | authors: 4 | - name: Delusoire 5 | url: https://github.com/Delusoire 6 | - name: khanhas 7 | - name: Tetrax-10 8 | tags: 9 | - enhanced 10 | --- 11 | -------------------------------------------------------------------------------- /extensions/play-enhanced-songs/assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/play-enhanced-songs/assets/preview.png -------------------------------------------------------------------------------- /extensions/search-on-youtube/app.ts: -------------------------------------------------------------------------------- 1 | import { searchYoutube, spotifyApi } from "../../shared/api.ts" 2 | import { _ } from "../../shared/deps.ts" 3 | import { parseWebAPITrack } from "../../shared/parse.ts" 4 | import { SpotifyID, SpotifyURI, normalizeStr } from "../../shared/util.ts" 5 | 6 | import { CONFIG } from "./settings.ts" 7 | 8 | const { URI, ContextMenu } = Spicetify 9 | 10 | const YTVidIDCache = new Map() 11 | 12 | const showOnYouTube = async (uri: SpotifyURI) => { 13 | const id = URI.fromString(uri)!.id! 14 | if (!YTVidIDCache.get(id)) { 15 | const track = parseWebAPITrack(await spotifyApi.tracks.get(id)) 16 | const searchString = `${track.artistName} - ${track.name} music video` 17 | 18 | try { 19 | const videos = await searchYoutube(CONFIG.YouTubeApiKey, searchString).then(res => res.items) 20 | const normalizedTrackName = normalizeStr(track.name) 21 | 22 | const video = 23 | videos.find(video => { 24 | normalizeStr(video.snippet.title).includes(normalizedTrackName) 25 | }) ?? videos[0] 26 | 27 | YTVidIDCache.set(id, video.id.videoId) 28 | 29 | window.open(`https://www.youtube.com/watch?v=${video.id.videoId}`) 30 | } catch (_) { 31 | window.open(`https://www.youtube.com/results?search_query=${encodeURIComponent(searchString)}`) 32 | } 33 | } 34 | } 35 | 36 | new ContextMenu.Item( 37 | "Search on YouTube", 38 | ([uri]) => showOnYouTube(uri), 39 | ([uri]) => _.overSome([URI.isTrack])(uri), 40 | ``, 41 | ).register() 42 | -------------------------------------------------------------------------------- /extensions/search-on-youtube/assets/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Search/Play the music video of the selected track on YouTube. Setting your own API Key allows you to bypass the search and directly open the first result. 3 | authors: 4 | - name: Delusoire 5 | url: https://github.com/Delusoire 6 | - name: Tetrax-10 7 | tags: 8 | - search 9 | - youtube 10 | --- 11 | -------------------------------------------------------------------------------- /extensions/search-on-youtube/assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/search-on-youtube/assets/preview.png -------------------------------------------------------------------------------- /extensions/search-on-youtube/settings.ts: -------------------------------------------------------------------------------- 1 | import { SettingsSection } from "../../shared/settings.tsx" 2 | 3 | const settings = new SettingsSection("Search On YouTube").addInput( 4 | { 5 | id: "YouTubeApiKey", 6 | desc: "YouTube API Key", 7 | inputType: "text", 8 | }, 9 | () => "***************************************", 10 | ) 11 | 12 | settings.pushSettings() 13 | 14 | export const CONFIG = settings.toObject() as { YouTubeApiKey: string } 15 | -------------------------------------------------------------------------------- /extensions/show-the-genres/app.ts: -------------------------------------------------------------------------------- 1 | import { fetchLastFMTrack, spotifyApi } from "../../shared/api.ts" 2 | import { pMchain } from "../../shared/fp.ts" 3 | import { SpotifyURI, waitForElement } from "../../shared/util.ts" 4 | 5 | import { CONFIG } from "./settings.ts" 6 | 7 | import "./assets/styles.scss" 8 | import "./components.ts" 9 | import { fetchArtistRelated } from "../../shared/GraphQL/fetchArtistRelated.ts" 10 | import { onHistoryChanged, onSongChanged } from "../../shared/listeners.ts" 11 | import { _ } from "../../shared/deps.ts" 12 | 13 | const { URI } = Spicetify 14 | 15 | const fetchLastFMTags = async (uri: SpotifyURI) => { 16 | const uid = URI.fromString(uri).id 17 | if (!uid) return [] 18 | const { name, artists } = await spotifyApi.tracks.get(uid) 19 | const artistNames = artists.map(artist => artist.name) 20 | const track = await fetchLastFMTrack(CONFIG.LFMApiKey, artistNames[0], name) 21 | const tags = track.toptags.tag.map(tag => tag.name) 22 | 23 | const deletedTagRegex = /^-\d{13}$/ 24 | const blacklistedTags = ["MySpotigramBot"] 25 | return tags.filter(tag => !deletedTagRegex.test(tag) && !blacklistedTags.includes(tag)) 26 | } 27 | 28 | const nowPlayingGenreContainerEl = document.createElement("genre-container") 29 | nowPlayingGenreContainerEl.fetchGenres = fetchLastFMTags 30 | nowPlayingGenreContainerEl.className += " ellipsis-one-line main-type-finale" 31 | nowPlayingGenreContainerEl.style.gridArea = "genres" 32 | ;(async () => { 33 | const trackInfoContainer = await waitForElement("div.main-trackInfo-container") 34 | trackInfoContainer.appendChild(nowPlayingGenreContainerEl) 35 | })() 36 | 37 | onSongChanged(state => { 38 | nowPlayingGenreContainerEl.uri = state.item?.uri 39 | }) 40 | 41 | const getArtistsGenresOrRelated = async (artistsUris: SpotifyURI[]) => { 42 | const getArtistsGenres = async (artistsUris: SpotifyURI[]) => { 43 | const ids = artistsUris.map(uri => URI.fromString(uri).id) 44 | const artists = await spotifyApi.artists.get(_.compact(ids)) 45 | const genres = new Set(artists.flatMap(artist => artist.genres)) 46 | return Array.from(genres) 47 | } 48 | 49 | const allGenres = await getArtistsGenres(artistsUris) 50 | 51 | if (allGenres.length) return allGenres 52 | 53 | const relatedArtists = await fetchArtistRelated(artistsUris[0]) 54 | 55 | relatedArtists.map(artist => artist.uri) 56 | 57 | if (allGenres.length) return allGenres 58 | 59 | const artistRelated = await fetchArtistRelated(artistsUris[0]) 60 | 61 | return _.chunk( 62 | artistRelated.map(a => a.uri), 63 | 5, 64 | ).reduce( 65 | async (acc, arr5uris) => ((await acc).length ? await acc : await getArtistsGenres(arr5uris)), 66 | Promise.resolve([] as string[]), 67 | ) 68 | } 69 | 70 | const updateArtistPage = async (uri: SpotifyURI) => { 71 | const artistGenreContainerEl = document.createElement("genre-container") 72 | artistGenreContainerEl.name = "Artist Genres" 73 | artistGenreContainerEl.uri = uri.toString() 74 | artistGenreContainerEl.fetchGenres = uri => getArtistsGenresOrRelated([uri]) 75 | 76 | const lastHeaderTextEl = document.querySelector("div.main-entityHeader-headerText") 77 | const headerTextEl = await waitForElement( 78 | "div.main-entityHeader-headerText", 79 | undefined, 80 | undefined, 81 | lastHeaderTextEl, 82 | ) 83 | const headerTextDetailsEl = await waitForElement("span.main-entityHeader-detailsText") 84 | headerTextEl.insertBefore(artistGenreContainerEl, headerTextDetailsEl) 85 | } 86 | 87 | onHistoryChanged(uri => URI.isArtist(uri), updateArtistPage) 88 | -------------------------------------------------------------------------------- /extensions/show-the-genres/assets/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Show the genres for the currently playing track and on artists' pages. This extensions requires you to use your own LastFM API Key. 3 | authors: 4 | - name: Delusoire 5 | url: https://github.com/Delusoire 6 | - name: Tetrax-10 7 | tags: 8 | - genres 9 | - lastfm 10 | --- 11 | -------------------------------------------------------------------------------- /extensions/show-the-genres/assets/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/show-the-genres/assets/preview.gif -------------------------------------------------------------------------------- /extensions/show-the-genres/assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/show-the-genres/assets/preview.png -------------------------------------------------------------------------------- /extensions/show-the-genres/assets/styles.css: -------------------------------------------------------------------------------- 1 | .main-nowPlayingWidget-trackInfo.main-trackInfo-container{grid-template:"title title" "badges subtitle" "genres genres"/auto 1fr auto} -------------------------------------------------------------------------------- /extensions/show-the-genres/assets/styles.scss: -------------------------------------------------------------------------------- 1 | .main-nowPlayingWidget-trackInfo.main-trackInfo-container { 2 | grid-template: 3 | "title title" 4 | "badges subtitle" 5 | "genres genres" / auto 1fr auto; 6 | } 7 | -------------------------------------------------------------------------------- /extensions/show-the-genres/components.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, PropertyValues, css, html } from "https://esm.sh/lit" 2 | import { customElement, property, state } from "https://esm.sh/lit/decorators.js" 3 | import { join } from "https://esm.sh/lit/directives/join.js" 4 | import { map } from "https://esm.sh/lit/directives/map.js" 5 | 6 | import { SpotifyURI } from "../../shared/util.ts" 7 | import { _ } from "../../shared/deps.ts" 8 | 9 | const { History } = Spicetify.Platform 10 | 11 | declare global { 12 | interface HTMLElementTagNameMap { 13 | "genre-container": _ArtistGenreContainer 14 | "genre-link": _GenreLink 15 | } 16 | } 17 | 18 | @customElement("genre-link") 19 | class _GenreLink extends LitElement { 20 | static styles = css` 21 | :host > a { 22 | color: var(--spice-subtext); 23 | font-size: var(--genre-link-size); 24 | } 25 | ` 26 | 27 | @property() 28 | genre = "No Genre" 29 | 30 | private openPlaylistsSearch() { 31 | History.push({ pathname: `/search/${this.genre}/playlists` }) 32 | } 33 | 34 | protected render() { 35 | return html`${_.startCase(this.genre)}` 36 | } 37 | } 38 | 39 | @customElement("genre-container") 40 | class _ArtistGenreContainer extends LitElement { 41 | @property() 42 | name?: string = undefined 43 | 44 | @property() 45 | uri?: SpotifyURI = undefined 46 | 47 | @state() 48 | genres: string[] = [] 49 | 50 | @property({ type: Boolean }) 51 | isSmall = true 52 | 53 | @property() 54 | fetchGenres = (uri: SpotifyURI) => Promise.resolve([uri]) 55 | 56 | protected willUpdate(changedProperties: PropertyValues) { 57 | if (changedProperties.has("uri")) { 58 | this.uri && this.fetchGenres(this.uri).then(genres => (this.genres = genres)) 59 | } 60 | } 61 | 62 | protected render() { 63 | const artistGenreLinks = map(this.genres, genre => html``) 64 | const divider = () => html`, ` 65 | 66 | return html` 71 |
72 | ${this.name && html`${this.name} : `} ${join(artistGenreLinks, divider)} 73 |
` 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /extensions/show-the-genres/settings.ts: -------------------------------------------------------------------------------- 1 | import { SettingsSection } from "../../shared/settings.tsx" 2 | 3 | const settings = new SettingsSection("Show The Genres").addInput( 4 | { 5 | id: "LFMApiKey", 6 | desc: "Last.fm API Key", 7 | inputType: "text", 8 | }, 9 | () => "********************************", 10 | ) 11 | 12 | settings.pushSettings() 13 | 14 | export const CONFIG = settings.toObject() as { 15 | LFMApiKey: string 16 | } 17 | -------------------------------------------------------------------------------- /extensions/sort-plus/app.ts: -------------------------------------------------------------------------------- 1 | import { _, fp } from "../../shared/deps.ts" 2 | import { TrackData } from "../../shared/parse.ts" 3 | import { createQueueItem, setQueue as _setQueue } from "../../shared/util.ts" 4 | 5 | import { createPlaylistFromLastSortedQueue, reordedPlaylistLikeSortedQueue } from "./playlistsInterop.ts" 6 | import { fillTracksFromLastFM, fillTracksFromSpotify } from "./populate.ts" 7 | import { CONFIG } from "./settings.ts" 8 | import { 9 | AsyncTracksOperation, 10 | SEPARATOR_URI, 11 | SortAction, 12 | SortActionIcon, 13 | SortActionProp, 14 | URI_isLikedTracks, 15 | getTracksFromUri, 16 | } from "./util.ts" 17 | 18 | const { URI, ContextMenu, Topbar } = Spicetify 19 | const { PlayerAPI } = Spicetify.Platform 20 | 21 | export let lastFetchedUri: string 22 | export let lastSortAction: SortAction | "True Shuffle" | "Stars" 23 | export let lastSortedQueue: TrackData[] = [] 24 | 25 | let invertOrder = 0 26 | addEventListener("keydown", event => { 27 | if (!event.repeat && event.key === "Shift") invertOrder = 1 28 | }) 29 | 30 | addEventListener("keyup", event => { 31 | if (!event.repeat && event.key === "Shift") invertOrder = 0 32 | }) 33 | 34 | const populateTracks: (sortProp: SortAction) => AsyncTracksOperation = _.cond([ 35 | [fp.startsWith("Spotify"), fillTracksFromSpotify], 36 | [fp.startsWith("LastFM"), () => fillTracksFromLastFM], 37 | ]) 38 | 39 | const setQueue = (tracks: TrackData[]) => { 40 | if (PlayerAPI._state.item?.uid == null) return void Spicetify.showNotification("Queue is null!", true) 41 | 42 | const dedupedQueue = _.uniqBy(tracks, "uri") 43 | 44 | global.lastSortedQueue = lastSortedQueue = dedupedQueue 45 | 46 | const isLikedTracks = URI_isLikedTracks(lastFetchedUri) 47 | 48 | const queue = lastSortedQueue.concat({ uri: SEPARATOR_URI } as TrackData).map(createQueueItem(isLikedTracks)) 49 | 50 | return _setQueue(queue, isLikedTracks ? undefined : lastFetchedUri) 51 | } 52 | 53 | // Menu 54 | 55 | const sortTracksBy = (sortAction: typeof lastSortAction, sortFn: AsyncTracksOperation) => async (uri: string) => { 56 | lastSortAction = sortAction 57 | const descending = invertOrder ^ Number(CONFIG.descending) 58 | lastFetchedUri = uri 59 | const tracks = await getTracksFromUri(uri) 60 | let sortedTracks = await sortFn(tracks) 61 | if (CONFIG.preventDuplicates) { 62 | sortedTracks = _.uniqBy(sortedTracks, "name") 63 | } 64 | descending && sortedTracks.reverse() 65 | return await setQueue(sortedTracks) 66 | } 67 | 68 | const createSubMenuForSortProp = (sortAction: SortAction) => 69 | new ContextMenu.Item( 70 | sortAction, 71 | ([uri]) => { 72 | const sortActionProp = SortActionProp[sortAction] 73 | const sortFn = async (tracks: TrackData[]) => { 74 | const filledTracks = await populateTracks(sortAction)(tracks) 75 | const filteredTracks = filledTracks.filter(track => track[sortActionProp] != null) 76 | return _.sortBy(filteredTracks, sortActionProp) 77 | } 78 | sortTracksBy(sortAction, sortFn)(uri) 79 | }, 80 | _.stubTrue, 81 | SortActionIcon[sortAction], 82 | false, 83 | ) 84 | const sortTracksByShuffle = sortTracksBy("True Shuffle", _.shuffle) 85 | const sortTracksByStars = sortTracksBy( 86 | "Stars", 87 | fp.sortBy((track: TrackData) => tracksRatings[track.uri] ?? 0), 88 | ) 89 | 90 | const SubMenuItems = Object.values(SortAction).map(createSubMenuForSortProp) 91 | const SubMenuItemShuffle = new ContextMenu.Item( 92 | "True Shuffle", 93 | ([uri]) => sortTracksByShuffle(uri), 94 | _.stubTrue, 95 | "shuffle", 96 | false, 97 | ) 98 | const SubMenuItemStars = new ContextMenu.Item( 99 | "Stars", 100 | ([uri]) => sortTracksByStars(uri), 101 | () => global.tracksRatings !== undefined, 102 | "heart-active", 103 | false, 104 | ) 105 | SubMenuItems.push(SubMenuItemShuffle, SubMenuItemStars) 106 | 107 | const SortBySubMenu = new ContextMenu.SubMenu("Sort by", SubMenuItems, ([uri]) => 108 | _.overSome([URI.isAlbum, URI.isArtist, URI_isLikedTracks, URI.isTrack, URI.isPlaylistV1OrV2])(uri), 109 | ) 110 | SortBySubMenu.register() 111 | 112 | // Topbar 113 | 114 | new Topbar.Button("Create a Playlist from Sorted Queue", "plus2px", createPlaylistFromLastSortedQueue) 115 | new Topbar.Button("Reorder Playlist like Sorted Queue", "chart-down", reordedPlaylistLikeSortedQueue) 116 | 117 | // Other 118 | 119 | new ContextMenu.Item( 120 | "Choose for Sorted Playlists", 121 | ([uri]) => (CONFIG.sortedPlaylistsFolderUri = uri), 122 | ([uri]) => URI.isFolder(uri), 123 | "playlist-folder", 124 | ).register() 125 | -------------------------------------------------------------------------------- /extensions/sort-plus/assets/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Sort your Playlists/Albums/Artists/Liked Songs on a bunch of metrics. 3 | authors: 4 | - name: Delusoire 5 | url: https://github.com/Delusoire 6 | - name: Tetrax-10 7 | tags: 8 | - sort 9 | - playcount 10 | - lastfm 11 | - shuffle 12 | --- 13 | 14 | i: This extension has settings that are accessible right under spotify's. 15 | 16 | ii: Holding Shift while sorting inverts the ascending order. 17 | -------------------------------------------------------------------------------- /extensions/sort-plus/assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/sort-plus/assets/preview.png -------------------------------------------------------------------------------- /extensions/sort-plus/fetch.ts: -------------------------------------------------------------------------------- 1 | import { fetchAlbum } from "../../shared/GraphQL/fetchAlbum.ts" 2 | import { fetchArtistDiscography } from "../../shared/GraphQL/fetchArtistDiscography.ts" 3 | import { fetchArtistOverview } from "../../shared/GraphQL/fetchArtistOveriew.ts" 4 | import { ItemMin, ItemsReleases, ItemsReleasesWithCount, ItemsWithCount } from "../../shared/GraphQL/sharedTypes.ts" 5 | import { _, fp } from "../../shared/deps.ts" 6 | import { pMchain } from "../../shared/fp.ts" 7 | import { 8 | TrackData, 9 | parseAlbumTrack, 10 | parseArtistLikedTrack, 11 | parseLibraryAPILikedTracks, 12 | parsePlaylistAPITrack, 13 | parseTopTrackFromArtist, 14 | } from "../../shared/parse.ts" 15 | import { fetchArtistLikedTracks, fetchLikedTracks, fetchPlaylistContents } from "../../shared/platformApi.ts" 16 | import { SpotifyURI } from "../../shared/util.ts" 17 | 18 | import { CONFIG } from "./settings.ts" 19 | 20 | const { URI } = Spicetify 21 | 22 | export const getTracksFromAlbum = async (uri: string) => { 23 | const albumRes = await fetchAlbum(uri) 24 | const releaseDate = new Date(albumRes.date.isoString).getTime() 25 | 26 | const filler = { 27 | albumUri: albumRes.uri, 28 | albumName: albumRes.name, 29 | releaseDate, 30 | } 31 | 32 | return Promise.all( 33 | albumRes.tracks.items.map(async track => { 34 | const parsedTrack = await parseAlbumTrack(track) 35 | return Object.assign(parsedTrack, filler) as TrackData 36 | }), 37 | ) 38 | } 39 | 40 | export const getLikedTracks = _.flow(fetchLikedTracks, pMchain(fp.map(parseLibraryAPILikedTracks))) 41 | 42 | export const getTracksFromPlaylist = _.flow( 43 | fetchPlaylistContents, 44 | pMchain(fp.map(parsePlaylistAPITrack)), 45 | pMchain(fp.filter(track => !URI.isLocalTrack(track.uri))), 46 | ) 47 | 48 | export const getTracksFromArtist = async (uri: SpotifyURI) => { 49 | const allTracks = new Array() 50 | 51 | const itemsWithCountAr = new Array>() 52 | const itemsReleasesAr = new Array>() 53 | const appearsOnAr = new Array>() 54 | 55 | if (CONFIG.artistAllDiscography) { 56 | const items = await fetchArtistDiscography(uri) 57 | itemsReleasesAr.push({ items, totalCount: Infinity }) 58 | } else { 59 | const { discography, relatedContent } = await fetchArtistOverview(uri) 60 | 61 | CONFIG.artistLikedTracks && allTracks.push(...(await fetchArtistLikedTracks(uri)).map(parseArtistLikedTrack)) 62 | CONFIG.artistTopTracks && allTracks.push(...discography.topTracks.items.map(parseTopTrackFromArtist)) 63 | CONFIG.artistPopularReleases && itemsWithCountAr.push(discography.popularReleasesAlbums) 64 | CONFIG.artistSingles && itemsReleasesAr.push(discography.singles) 65 | CONFIG.artistAlbums && itemsReleasesAr.push(discography.albums) 66 | CONFIG.artistCompilations && itemsReleasesAr.push(discography.compilations) 67 | CONFIG.artistAppearsOn && appearsOnAr.push(relatedContent.appearsOn) 68 | } 69 | 70 | const items1 = itemsWithCountAr.flatMap(iwc => iwc.items) 71 | const items2 = itemsReleasesAr.flatMap(ir => ir.items.flatMap(i => i.releases.items)) 72 | const albumLikeUris = items1.concat(items2).map(item => item.uri) 73 | const albumsTracks = await Promise.all(albumLikeUris.map(getTracksFromAlbum)) 74 | 75 | const appearsOnUris = appearsOnAr.flatMap(ir => ir.items.flatMap(i => i.releases.items)).map(item => item.uri) 76 | const appearsOnTracks = await Promise.all(appearsOnUris.map(getTracksFromAlbum)) 77 | 78 | allTracks.push(...albumsTracks.flat(), ...appearsOnTracks.flat().filter(track => track.artistUris.includes(uri))) 79 | return await Promise.all(allTracks) 80 | } 81 | -------------------------------------------------------------------------------- /extensions/sort-plus/playlistsInterop.ts: -------------------------------------------------------------------------------- 1 | import { _ } from "../../shared/deps.ts" 2 | import { progressify } from "../../shared/fp.ts" 3 | import { 4 | createPlaylistFromTracks, 5 | fetchFolder, 6 | fetchPlaylistContents, 7 | fetchRootFolder, 8 | movePlaylistTracks, 9 | setPlaylistVisibility, 10 | } from "../../shared/platformApi.ts" 11 | import { SpotifyLoc } from "../../shared/util.ts" 12 | 13 | import { lastFetchedUri, lastSortAction } from "./app.ts" 14 | import { CONFIG } from "./settings.ts" 15 | import { ERROR, getNameFromUri } from "./util.ts" 16 | 17 | const { URI } = Spicetify 18 | 19 | export const createPlaylistFromLastSortedQueue = async () => { 20 | if (lastSortedQueue.length === 0) { 21 | Spicetify.showNotification(ERROR.LAST_SORTED_QUEUE_EMPTY) 22 | return 23 | } 24 | 25 | const sortedPlaylistsFolder = await fetchFolder(CONFIG.sortedPlaylistsFolderUri).catch(fetchRootFolder) 26 | 27 | const uri = URI.fromString(lastFetchedUri) 28 | const playlistName = `${await getNameFromUri(uri)} - ${lastSortAction}` 29 | 30 | const { success, uri: playlistUri } = await createPlaylistFromTracks( 31 | playlistName, 32 | lastSortedQueue.map(t => t.uri), 33 | sortedPlaylistsFolder.uri, 34 | ) 35 | 36 | if (!success) { 37 | Spicetify.showNotification(`Failed to create Playlist ${playlistName}`, true) 38 | return 39 | } 40 | 41 | setPlaylistVisibility(playlistUri, false) 42 | Spicetify.showNotification(`Playlist ${playlistName} created`) 43 | } 44 | 45 | export const reordedPlaylistLikeSortedQueue = async () => { 46 | if (lastSortedQueue.length === 0) { 47 | Spicetify.showNotification(ERROR.LAST_SORTED_QUEUE_EMPTY) 48 | return 49 | } 50 | 51 | if (!URI.isPlaylistV1OrV2(lastFetchedUri)) { 52 | Spicetify.showNotification(ERROR.LAST_SORTED_QUEUE_NOT_A_PLAYLIST) 53 | return 54 | } 55 | 56 | const sortedUids = lastSortedQueue.map(track => track.uid!) 57 | const playlistUids = (await fetchPlaylistContents(lastFetchedUri)).map(item => item.uid) 58 | 59 | let i = sortedUids.length - 1 60 | let uidsByReqs = new Array() 61 | while (i >= 0) { 62 | const uids = new Array() 63 | 64 | _.forEachRight(playlistUids, (uid, j) => { 65 | if (uid === sortedUids[i]) { 66 | i-- 67 | playlistUids.splice(j, 1) 68 | uids.push(uid) 69 | } 70 | }) 71 | 72 | uidsByReqs.push(uids.reverse()) 73 | } 74 | 75 | const fn = progressify( 76 | (uids: string[]) => movePlaylistTracks(lastFetchedUri, uids, SpotifyLoc.before.start()), 77 | uidsByReqs.length, 78 | ) 79 | 80 | await Promise.all(uidsByReqs.map(fn)) 81 | 82 | Spicetify.showNotification(`Reordered the sorted playlist`) 83 | if (playlistUids.length) { 84 | Spicetify.showNotification(`Left ${playlistUids.length} unordered at the bottom`) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /extensions/sort-plus/populate.ts: -------------------------------------------------------------------------------- 1 | import { fetchLastFMTrack, spotifyApi } from "../../shared/api.ts" 2 | import { _, fp } from "../../shared/deps.ts" 3 | import { chunkify50, progressify } from "../../shared/fp.ts" 4 | import { TrackData, parseWebAPITrack } from "../../shared/parse.ts" 5 | 6 | import { getTracksFromAlbum } from "./fetch.ts" 7 | import { CONFIG } from "./settings.ts" 8 | import { SortAction, SortActionProp, joinByUri } from "./util.ts" 9 | 10 | const { URI } = Spicetify 11 | 12 | const fillTracksFromWebAPI = async (tracks: TrackData[]) => { 13 | const ids = tracks.map(track => URI.fromString(track.uri)!.id!) 14 | 15 | const fetchedTracks = await chunkify50(is => spotifyApi.tracks.get(is))(ids) 16 | return joinByUri(tracks, fetchedTracks.map(parseWebAPITrack)) 17 | } 18 | 19 | const fillTracksFromAlbumTracks = async (tracks: TrackData[]) => { 20 | const tracksByAlbumUri = Object.groupBy(tracks, track => track.albumUri) 21 | const passes = Object.keys(tracksByAlbumUri).length 22 | const fn = progressify(async (tracks: TrackData[]) => { 23 | const albumTracks = await getTracksFromAlbum(tracks[0].albumUri) 24 | const newTracks = _.intersectionBy(albumTracks, tracks, track => track.uri) 25 | return joinByUri(tracks, newTracks) 26 | }, passes) 27 | 28 | const sameAlbumTracksArray = Object.values(tracksByAlbumUri) 29 | const albumsTracks = await Promise.all(sameAlbumTracksArray.map(fn)) 30 | return albumsTracks.flat() 31 | } 32 | 33 | export const fillTracksFromSpotify = (propName: SortAction) => async (tracks: TrackData[]) => { 34 | const tracksMissing = tracks.filter(track => track[SortActionProp[propName]] == null) 35 | const tracksPopulater = _.cond([ 36 | [fp.startsWith(SortAction.SPOTIFY_PLAYCOUNT), () => fillTracksFromAlbumTracks], 37 | [_.stubTrue, () => fillTracksFromWebAPI], 38 | ])(propName) 39 | const filledTracks = await tracksPopulater(tracksMissing) 40 | return joinByUri(tracks, filledTracks) 41 | } 42 | 43 | const fillTrackFromLastFM = async (track: TrackData) => { 44 | const lastfmTrack = await fetchLastFMTrack(CONFIG.LFMApiKey, track.artistName, track.name, CONFIG.lastFmUsername) 45 | track.lastfmPlaycount = Number(lastfmTrack.listeners) 46 | track.scrobbles = Number(lastfmTrack.playcount) 47 | track.personalScrobbles = Number(lastfmTrack.userplaycount) 48 | return track 49 | } 50 | 51 | export const fillTracksFromLastFM = (tracks: TrackData[]) => { 52 | const fn = progressify(fillTrackFromLastFM, tracks.length) 53 | return Promise.all(tracks.map(fn)) 54 | } 55 | -------------------------------------------------------------------------------- /extensions/sort-plus/settings.ts: -------------------------------------------------------------------------------- 1 | import { createFolder } from "../../shared/platformApi.ts" 2 | import { SettingsSection } from "../../shared/settings.tsx" 3 | import { SpotifyURI } from "../../shared/util.ts" 4 | 5 | const SORTED_PLAYLISTS_FOLDER_NAME = "📀 Sorted Playlists" 6 | 7 | const settings = new SettingsSection("Sort Plus") 8 | .addToggle({ id: "preventDuplicates", desc: "Prevent Duplicates" }, () => true) 9 | .addToggle({ id: "descending", desc: "Descending" }, () => true) 10 | .addToggle({ id: "artistAllDiscography", desc: "All of the artist's Discography" }) 11 | .addToggle({ id: "artistTopTracks", desc: "Top Tracks" }, () => true) 12 | .addToggle({ id: "artistPopularReleases", desc: "Popular Releases" }, () => true) 13 | .addToggle({ id: "artistSingles", desc: "Singles" }) 14 | .addToggle({ id: "artistAlbums", desc: "Albums" }) 15 | .addToggle({ id: "artistCompilations", desc: "Compilations" }) 16 | .addToggle({ id: "artistLikedTracks", desc: "Liked Tracks" }, () => true) 17 | .addToggle({ id: "artistAppearsOn", desc: "Appears On" }) 18 | .addInput({ id: "lastFmUsername", desc: "Last.fm Username", inputType: "text" }, () => "Username") 19 | .addInput({ id: "LFMApiKey", desc: "Last.fm API Key", inputType: "text" }, () => "********************************") 20 | .addInput( 21 | { 22 | id: "sortedPlaylistsFolderUri", 23 | desc: "Sorted Playlists folder uri", 24 | inputType: "text", 25 | }, 26 | async () => (await createFolder(SORTED_PLAYLISTS_FOLDER_NAME)).uri, 27 | ) 28 | 29 | settings.pushSettings() 30 | 31 | export const CONFIG = settings.toObject() as { 32 | preventDuplicates: boolean 33 | artistAllDiscography: boolean 34 | artistTopTracks: boolean 35 | artistPopularReleases: boolean 36 | artistSingles: boolean 37 | artistAlbums: boolean 38 | artistCompilations: boolean 39 | artistLikedTracks: boolean 40 | artistAppearsOn: boolean 41 | descending: boolean 42 | lastFmUsername: string 43 | LFMApiKey: string 44 | sortedPlaylistsFolderUri: SpotifyURI 45 | } 46 | -------------------------------------------------------------------------------- /extensions/sort-plus/util.ts: -------------------------------------------------------------------------------- 1 | import { spotifyApi } from "../../shared/api.ts" 2 | import { _, fp } from "../../shared/deps.ts" 3 | import { TrackData } from "../../shared/parse.ts" 4 | 5 | import { getLikedTracks, getTracksFromAlbum, getTracksFromArtist, getTracksFromPlaylist } from "./fetch.ts" 6 | 7 | const { URI } = Spicetify 8 | 9 | export const SEPARATOR_URI = "spotify:separator" 10 | 11 | export enum ERROR { 12 | LAST_SORTED_QUEUE_EMPTY = "Must sort to queue beforehand", 13 | LAST_SORTED_QUEUE_NOT_A_PLAYLIST = "Last sorted queue must be a playlist", 14 | } 15 | 16 | export type AsyncTracksOperation = (tracks: TrackData[]) => Promise | TrackData[] 17 | 18 | export enum SortAction { 19 | SPOTIFY_PLAYCOUNT = "Spotify - Play Count", 20 | SPOTIFY_POPULARITY = "Spotify - Popularity", 21 | SPOTIFY_RELEASEDATE = "Spotify - Release Date", 22 | LASTFM_SCROBBLES = "LastFM - Scrobbles", 23 | LASTFM_PERSONALSCROBBLES = "LastFM - My Scrobbles", 24 | LASTFM_PLAYCOUNT = "LastFM - Play Count", 25 | } 26 | 27 | export enum SortActionIcon { 28 | "Spotify - Play Count" = "play", 29 | "Spotify - Popularity" = "heart", 30 | "Spotify - Release Date" = "list-view", 31 | "LastFM - Scrobbles" = "volume", 32 | "LastFM - My Scrobbles" = "artist", 33 | "LastFM - Play Count" = "subtitles", 34 | } 35 | 36 | export enum SortActionProp { 37 | "Spotify - Play Count" = "playcount", 38 | "Spotify - Popularity" = "popularity", 39 | "Spotify - Release Date" = "releaseDate", 40 | "LastFM - Scrobbles" = "scrobbles", 41 | "LastFM - My Scrobbles" = "personalScrobbles", 42 | "LastFM - Play Count" = "lastfmPlaycount", 43 | } 44 | 45 | export const joinByUri = (...trackss: TrackData[][]) => 46 | _(trackss) 47 | .flatten() 48 | .map(fp.omitBy(_.isNil)) 49 | .groupBy("uri") 50 | .mapValues(sameUriTracks => Object.assign({}, ...sameUriTracks) as TrackData) 51 | .values() 52 | .value() 53 | 54 | export const URI_isLikedTracks = (uri: string) => { 55 | const uriObj = URI.fromString(uri) 56 | return uriObj.type === URI.Type.COLLECTION && uriObj.category === "tracks" 57 | } 58 | 59 | export const getNameFromUri = async (uri: Spicetify.URI) => { 60 | switch (uri.type) { 61 | case URI.Type.ALBUM: { 62 | const album = await spotifyApi.albums.get(uri.id!) 63 | return album.name 64 | } 65 | 66 | case URI.Type.ARTIST: { 67 | const artist = await spotifyApi.artists.get(uri.id!) 68 | return artist.name 69 | } 70 | 71 | case URI.Type.COLLECTION: 72 | if (uri.category === "tracks") return "Liked Tracks" 73 | else break 74 | 75 | case URI.Type.PLAYLIST: 76 | case URI.Type.PLAYLIST_V2: { 77 | const playlist = await spotifyApi.playlists.getPlaylist(uri.id!) 78 | return playlist.name 79 | } 80 | } 81 | } 82 | 83 | export const getTracksFromUri = _.cond([ 84 | [URI.isAlbum, getTracksFromAlbum], 85 | [URI.isArtist, getTracksFromArtist], 86 | [URI_isLikedTracks, getLikedTracks], 87 | [URI.isPlaylistV1OrV2, getTracksFromPlaylist], 88 | ]) 89 | -------------------------------------------------------------------------------- /extensions/spoqify-radios/app.ts: -------------------------------------------------------------------------------- 1 | import { _ } from "../../shared/deps.ts" 2 | import { SpotifyLoc, SpotifyURI } from "../../shared/util.ts" 3 | 4 | import { CONFIG } from "./settings.ts" 5 | 6 | const { URI, ContextMenu } = Spicetify 7 | const { History, RootlistAPI } = Spicetify.Platform 8 | 9 | const createAnonRadio = (uri: SpotifyURI) => { 10 | const sse = new EventSource(`https://open.spoqify.com/anonymize?url=${uri.substring(8)}`) 11 | sse.addEventListener("done", e => { 12 | sse.close() 13 | const anonUri = URI.fromString(e.data) 14 | 15 | History.push(anonUri.toURLPath(true)) 16 | RootlistAPI.add([anonUri.toURI()], SpotifyLoc.after.fromUri(CONFIG.anonymizedRadiosFolderUri)) 17 | }) 18 | } 19 | 20 | new ContextMenu.Item( 21 | "Create anonymized radio", 22 | ([uri]) => createAnonRadio(uri), 23 | ([uri]) => _.overSome([URI.isAlbum, URI.isArtist, URI.isPlaylistV1OrV2, URI.isTrack])(uri), 24 | "podcasts", 25 | ).register() 26 | 27 | new ContextMenu.Item( 28 | "Choose for Anonymized Radios", 29 | ([uri]) => (CONFIG.anonymizedRadiosFolderUri = uri), 30 | ([uri]) => URI.isFolder(uri), 31 | "playlist-folder", 32 | ).register() 33 | -------------------------------------------------------------------------------- /extensions/spoqify-radios/assets/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Create anonymized radio-playlists for your favorite songs, playlists, artists and albums. 3 | 4 | authors: 5 | - name: Delusoire 6 | url: https://github.com/Delusoire 7 | - name: BitesizedLion 8 | tags: 9 | - radio 10 | - spoqify 11 | --- 12 | 13 | Credit: spoqify 14 | -------------------------------------------------------------------------------- /extensions/spoqify-radios/assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/spoqify-radios/assets/preview.png -------------------------------------------------------------------------------- /extensions/spoqify-radios/settings.ts: -------------------------------------------------------------------------------- 1 | import { createFolder } from "../../shared/platformApi.ts" 2 | import { SettingsSection } from "../../shared/settings.tsx" 3 | import { SpotifyURI } from "../../shared/util.ts" 4 | 5 | const ANONIMYZED_RADIOS_FOLDER_NAME = "Anonymized Radios" 6 | 7 | const settings = new SettingsSection("Spoqify Radios").addInput( 8 | { 9 | id: "anonymizedRadiosFolderUri", 10 | desc: "Anonymized Radios folder uri", 11 | inputType: "text", 12 | }, 13 | async () => (await createFolder(ANONIMYZED_RADIOS_FOLDER_NAME)).uri, 14 | ) 15 | 16 | settings.pushSettings() 17 | 18 | export const CONFIG = settings.toObject() as { 19 | anonymizedRadiosFolderUri: SpotifyURI 20 | } 21 | -------------------------------------------------------------------------------- /extensions/star-ratings-2/app.ts: -------------------------------------------------------------------------------- 1 | import { updateCollectionControls, updateNowPlayingControls, updateTrackControls } from "./controls.tsx" 2 | import { loadRatings, tracksRatings } from "./ratings.ts" 3 | import { CONFIG } from "./settings.ts" 4 | 5 | import "./assets/styles.scss" 6 | import { _ } from "../../shared/deps.ts" 7 | import { onHistoryChanged, onSongChanged, onTrackListMutationListeners } from "../../shared/listeners.ts" 8 | const { URI, Player, ContextMenu } = Spicetify 9 | 10 | loadRatings() 11 | 12 | onSongChanged(state => { 13 | if (!state) return 14 | const { uri } = state.item ?? {} 15 | if (!uri) return 16 | 17 | if (Number(CONFIG.skipThreshold)) { 18 | const currentTrackRating = tracksRatings[uri] ?? Number.MAX_SAFE_INTEGER 19 | if (currentTrackRating <= Number(CONFIG.skipThreshold)) return void Player.next() 20 | } 21 | 22 | updateNowPlayingControls(uri) 23 | }) 24 | 25 | onTrackListMutationListeners.push(async (_, tracks) => { 26 | for (const track of tracks) updateTrackControls(track, track.props.uri) 27 | }) 28 | 29 | onHistoryChanged(_.overSome([URI.isAlbum, URI.isArtist, URI.isPlaylistV1OrV2]), uri => updateCollectionControls(uri)) 30 | 31 | new ContextMenu.Item( 32 | "Choose for Ratings Playlists", 33 | ([uri]) => (CONFIG.ratingsFolderUri = uri), 34 | ([uri]) => URI.isFolder(uri), 35 | "playlist-folder", 36 | ).register() 37 | -------------------------------------------------------------------------------- /extensions/star-ratings-2/assets/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Rate your songs with stars! And show aggregated Show album/playlist/artist stars. 3 | authors: 4 | - name: Delusoire 5 | url: https://github.com/Delusoire 6 | tags: 7 | - like 8 | - star 9 | - rate 10 | --- 11 | -------------------------------------------------------------------------------- /extensions/star-ratings-2/assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/star-ratings-2/assets/preview.png -------------------------------------------------------------------------------- /extensions/star-ratings-2/assets/styles.css: -------------------------------------------------------------------------------- 1 | button.rating-1 svg{fill:#ed5564 !important}button.rating-2 svg{fill:#ffce54 !important}button.rating-3 svg{fill:#a0d568 !important}button.rating-4 svg{fill:#4fc1e8 !important}button.rating-5 svg{fill:#ac92eb !important} -------------------------------------------------------------------------------- /extensions/star-ratings-2/assets/styles.scss: -------------------------------------------------------------------------------- 1 | button.rating-1 svg { 2 | fill: #ed5564 !important; 3 | } 4 | 5 | button.rating-2 svg { 6 | fill: #ffce54 !important; 7 | } 8 | 9 | button.rating-3 svg { 10 | fill: #a0d568 !important; 11 | } 12 | 13 | button.rating-4 svg { 14 | fill: #4fc1e8 !important; 15 | } 16 | 17 | button.rating-5 svg { 18 | fill: #ac92eb !important; 19 | } 20 | -------------------------------------------------------------------------------- /extensions/star-ratings-2/controls.tsx: -------------------------------------------------------------------------------- 1 | import { Instance, Props } from "npm:tippy.js" 2 | 3 | import { SpotifyURI } from "../../shared/util.ts" 4 | 5 | import { _, fp } from "../../shared/deps.ts" 6 | import { getTracksFromUri } from "../sort-plus/util.ts" 7 | import { Dropdown } from "./dropdown.tsx" 8 | import { tracksRatings } from "./ratings.ts" 9 | import { 10 | getCollectionPlaylistButton, 11 | getNowPlayingBar, 12 | getPlaylistButton, 13 | getTrackListTrackUri, 14 | getTrackListTracks, 15 | getTrackLists, 16 | } from "./util.ts" 17 | 18 | const { URI, Tippy } = Spicetify 19 | const { React, ReactDOM } = Spicetify 20 | 21 | const UNSET_CSS = "invalid" 22 | const colorByRating = [UNSET_CSS, "#ED5564", "#FFCE54", "A0D568", "#4FC1E8", "#AC92EB"] 23 | 24 | const colorizePlaylistButton = (btn: HTMLButtonElement, rating: number) => { 25 | if (btn.style.fill === colorByRating[rating]) return 26 | 27 | // Do we need this anymore? 28 | btn.style.opacity = rating > 0 ? "1" : UNSET_CSS 29 | const svg = btn.querySelector("svg") 30 | if (!svg) return 31 | svg.style.fill = colorByRating[rating] 32 | } 33 | 34 | let lastNPTippyInstance: Instance 35 | const wrapDropdownInsidePlaylistButton = (pb: HTMLButtonElement, uri: SpotifyURI, forced = false) => { 36 | if (pb.hasAttribute("dropdown-enabled")) { 37 | if (!forced) return 38 | } else pb.setAttribute("dropdown-enabled", "") 39 | 40 | const div = document.createElement("div") 41 | 42 | pb.appendChild(div) 43 | ReactDOM.render(, div) 44 | const tippyInstance = Tippy(pb, { 45 | content: div, 46 | interactive: true, 47 | animateFill: false, 48 | //offset: [0, 7], 49 | placement: "left", 50 | animation: "fade", 51 | //trigger: "mouseenter focus", 52 | zIndex: 1e4, 53 | delay: [200, 0], 54 | render(instance: any) { 55 | const popper = document.createElement("div") 56 | const box = document.createElement("div") 57 | 58 | popper.id = "context-menu" 59 | popper.appendChild(box) 60 | 61 | box.className = "main-contextMenu-tippy" 62 | box.appendChild(instance.props.content) 63 | 64 | return { popper, onUpdate: () => undefined } 65 | }, 66 | onShow(instance: any) { 67 | instance.popper.firstChild.classList.add("main-contextMenu-tippyEnter") 68 | 69 | const children = (instance.reference.parentElement as HTMLDivElement).children 70 | const element = children.item(children.length - 1) as HTMLButtonElement 71 | element.style.marginRight = "0px" 72 | }, 73 | onMount(instance: any) { 74 | requestAnimationFrame(() => { 75 | instance.popper.firstChild.classList.remove("main-contextMenu-tippyEnter") 76 | instance.popper.firstChild.classList.add("main-contextMenu-tippyEnterActive") 77 | }) 78 | }, 79 | onHide(instance: any) { 80 | requestAnimationFrame(() => { 81 | instance.popper.firstChild.classList.remove("main-contextMenu-tippyEnterActive") 82 | 83 | const children = (instance.reference.parentElement as HTMLDivElement).children 84 | const element = children.item(children.length - 2) as HTMLButtonElement 85 | element.style.marginRight = "unset" 86 | 87 | instance.unmount() 88 | }) 89 | }, 90 | }) 91 | 92 | if (forced) { 93 | lastNPTippyInstance?.destroy() 94 | lastNPTippyInstance = tippyInstance 95 | } 96 | } 97 | 98 | export const updateNowPlayingControls = (newTrack: SpotifyURI, updateDropdown = true) => { 99 | const npb = getNowPlayingBar() 100 | const pb = getPlaylistButton(npb) 101 | colorizePlaylistButton(pb, tracksRatings[newTrack]) 102 | if (updateDropdown) wrapDropdownInsidePlaylistButton(pb, newTrack, true) 103 | } 104 | 105 | export const updateTrackControls = (track: HTMLElement, uri: string, updateDropdown = true) => { 106 | if (!URI.isTrack(uri)) return 107 | const r = tracksRatings[uri] 108 | const pb = getPlaylistButton(track) 109 | 110 | colorizePlaylistButton(pb, r) 111 | updateDropdown && wrapDropdownInsidePlaylistButton(pb, uri) 112 | } 113 | 114 | export const updateTrackListControls = (updateDropdown = true) => 115 | getTrackLists() 116 | .map(getTrackListTracks) 117 | .map( 118 | fp.map(track => { 119 | const uri = getTrackListTrackUri(track) 120 | updateTrackControls(track, uri, updateDropdown) 121 | }), 122 | ) 123 | 124 | export const updateCollectionControls = async (uri: string) => { 125 | const tracks = await getTracksFromUri(uri) 126 | const ratings = _.compact(tracks.map(track => tracksRatings[track.uri])) 127 | const rating = Math.round(ratings.reduce((psum, r) => psum + r, 0) / ratings.length) 128 | 129 | const pb = getCollectionPlaylistButton() 130 | pb && colorizePlaylistButton(pb, rating) 131 | } 132 | -------------------------------------------------------------------------------- /extensions/star-ratings-2/dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { CheckedPlaylistButtonIcon, curationButtonClass } from "../../shared/modules.ts" 2 | import { SpotifyURI } from "../../shared/util.ts" 3 | 4 | import { toggleRating } from "./ratings.ts" 5 | import { _ } from "../../shared/deps.ts" 6 | 7 | const { React } = Spicetify 8 | 9 | const { ButtonTertiary } = Spicetify.ReactComponent 10 | 11 | const RatingButton = ({ i, uri }: { i: number; uri: SpotifyURI }) => ( 12 | toggleRating(uri, i)} 22 | /> 23 | ) 24 | 25 | export const Dropdown = ({ uri }: { uri: SpotifyURI }) => ( 26 |
27 | {_.range(1, 6).map(i => ( 28 | 29 | ))} 30 |
31 | ) 32 | -------------------------------------------------------------------------------- /extensions/star-ratings-2/ratings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addPlaylistTracks, 3 | createPlaylist, 4 | fetchFolder, 5 | fetchPlaylistContents, 6 | removePlaylistTracks, 7 | setPlaylistVisibility, 8 | setTracksLiked, 9 | } from "../../shared/platformApi.ts" 10 | import { SpotifyLoc, SpotifyURI } from "../../shared/util.ts" 11 | 12 | import { updateCollectionControls, updateNowPlayingControls, updateTrackListControls } from "./controls.tsx" 13 | import { CONFIG } from "./settings.ts" 14 | import { getNowPlayingBar } from "./util.ts" 15 | import { _, fp } from "../../shared/deps.ts" 16 | 17 | const { URI } = Spicetify 18 | const { History, PlayerAPI } = Spicetify.Platform 19 | 20 | export const loadRatings = async () => { 21 | const ratingsFolder = await fetchFolder(CONFIG.ratingsFolderUri) 22 | 23 | playlistUris = ratingsFolder.items 24 | .map(p => [p.uri, Number(p.name)] as const) 25 | .reduce((uris, [uri, rating]) => { 26 | uris[rating] = uri 27 | return uris 28 | }, [] as string[]) 29 | 30 | const playlists = await Promise.all(playlistUris.map(fetchPlaylistContents)) 31 | global.tracksRatings = tracksRatings = playlists 32 | .flatMap((tracks, rating) => tracks?.map(t => [t.uri, rating] as const) ?? []) 33 | .reduce( 34 | (acc, [trackUri, rating]) => 35 | Object.assign(acc, { 36 | [trackUri]: Math.max(rating, acc[trackUri] ?? 0), 37 | }), 38 | {} as Record, 39 | ) 40 | } 41 | 42 | export const toggleRating = async (uri: SpotifyURI, rating: number) => { 43 | const currentRating = tracksRatings[uri] 44 | 45 | if (currentRating === rating) rating = 0 46 | 47 | if (currentRating) { 48 | const playlistIds = _.compact(playlistUris.slice(0, currentRating + 1)).map( 49 | playlistUri => URI.fromString(playlistUri).id, 50 | ) 51 | 52 | for (const playlistId of playlistIds) { 53 | removePlaylistTracks(playlistId, [{ uri, uid: "" } as { uid: string }]) 54 | } 55 | } 56 | 57 | tracksRatings[uri] = rating 58 | 59 | if (rating > 0) { 60 | let playlistUri = playlistUris[rating] as string | undefined | null 61 | 62 | if (!playlistUri) { 63 | playlistUri = (await createPlaylist( 64 | rating.toFixed(0), 65 | SpotifyLoc.after.fromUri(CONFIG.ratingsFolderUri), 66 | )) as string 67 | setPlaylistVisibility(playlistUri, false) 68 | playlistUris[rating] = playlistUri 69 | } 70 | 71 | addPlaylistTracks(playlistUri, [uri]) 72 | 73 | if (rating >= Number(CONFIG.heartThreshold)) { 74 | setTracksLiked([uri], true) 75 | } 76 | } 77 | 78 | const npTrack = PlayerAPI._state.item?.uri 79 | if (npTrack === uri) { 80 | updateNowPlayingControls(npTrack, false) 81 | 82 | //TODO: clean this 83 | { 84 | new MutationObserver((_, observer) => { 85 | observer.disconnect() 86 | if (npTrack !== uri) return 87 | updateNowPlayingControls(npTrack, false) 88 | }).observe(getNowPlayingBar(), { 89 | subtree: true, 90 | }) 91 | } 92 | } 93 | 94 | //TODO: Optimize this, find a way to directly target the pbs for that uri 95 | updateTrackListControls() 96 | const { pathname } = History.location 97 | updateCollectionControls(URI.fromString(pathname).toString()) 98 | } 99 | 100 | export let playlistUris: SpotifyURI[] = [] 101 | export let tracksRatings: Record = {} 102 | -------------------------------------------------------------------------------- /extensions/star-ratings-2/settings.ts: -------------------------------------------------------------------------------- 1 | import { createFolder } from "../../shared/platformApi.ts" 2 | import { SettingsSection } from "../../shared/settings.tsx" 3 | import { SpotifyURI } from "../../shared/util.ts" 4 | 5 | import { loadRatings } from "./ratings.ts" 6 | 7 | const RATINGS_FOLDER_NAME = "Ratings" 8 | 9 | const settings = new SettingsSection("Star Ratings 2") 10 | .addInput({ id: "heartThreshold", desc: "Threshold for liking trakcs", inputType: "number" }, () => "3") 11 | .addInput({ id: "skipThreshold", desc: "Threshold for skipping trakcs", inputType: "number" }, () => "1") 12 | .addInput( 13 | { 14 | id: "ratingsFolderUri", 15 | desc: "Ratings folder uri", 16 | inputType: "text", 17 | onChange: loadRatings, 18 | }, 19 | async () => (await createFolder(RATINGS_FOLDER_NAME)).uri, 20 | ) 21 | 22 | settings.pushSettings() 23 | 24 | export const CONFIG = settings.toObject() as { 25 | heartThreshold: string 26 | skipThreshold: string 27 | ratingsFolderUri: SpotifyURI 28 | } 29 | -------------------------------------------------------------------------------- /extensions/star-ratings-2/util.ts: -------------------------------------------------------------------------------- 1 | import { REACT_PROPS } from "../../shared/util.ts" 2 | 3 | export const RATINGS_FOLDER_NAME = "Ratings" 4 | 5 | export const getTrackLists = () => 6 | Array.from(document.querySelectorAll(".main-trackList-trackList.main-trackList-indexable")) 7 | export const getTrackListTracks = (trackList: HTMLDivElement) => 8 | Array.from(trackList.querySelectorAll(".main-trackList-trackListRow")) 9 | 10 | export const getTrackListTrackUri = (track: HTMLDivElement) => { 11 | const rowSectionEnd = track.querySelector(".main-trackList-rowSectionEnd")! 12 | const reactProps = rowSectionEnd[REACT_PROPS] 13 | const { props } = 14 | // artist & local tracks & albums 15 | reactProps.children.at?.(-1).props.menu ?? 16 | // playlists 17 | reactProps.children.props.children.at(-1).props.menu 18 | 19 | return props.uri 20 | } 21 | 22 | export const getNowPlayingBar = () => document.querySelector("div.main-nowPlayingBar-nowPlayingBar")! 23 | export const getCollectionActionBarRow = () => document.querySelector(`div.main-actionBar-ActionBarRow`) 24 | 25 | export const playlistButtonSelector = `button[aria-label="Add to Liked Songs"], button[aria-label="Add to playlist"], button[aria-label="Remove recommendation"]` 26 | export const getPlaylistButton = (parent: HTMLElement) => 27 | parent.querySelector(playlistButtonSelector)! 28 | export const getCollectionPlaylistButton = () => { 29 | const ab = getCollectionActionBarRow() 30 | return ab?.querySelector( 31 | `button[aria-label="Remove from Your Library"], button[aria-label="Save to Your Library"]`, 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /extensions/star-ratings/app.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from "https://esm.sh/lit" 2 | 3 | import { onTrackListMutationListeners } from "../../shared/listeners.ts" 4 | 5 | import { CONFIG } from "./settings.ts" 6 | import { getLastCol, getTrackListHeader, getTracksPlaylists } from "./util.ts" 7 | 8 | const customTrackListColCss = [ 9 | null, 10 | null, 11 | null, 12 | null, 13 | "[index] 16px [first] 4fr [var1] 2fr [var2] 1fr [last] minmax(120px,1fr)", 14 | "[index] 16px [first] 6fr [var1] 4fr [var2] 3fr [var3] 2fr [last] minmax(120px,1fr)", 15 | "[index] 16px [first] 6fr [var1] 4fr [var2] 3fr [var3] minmax(120px,2fr) [var3] 2fr [last] minmax(120px,1fr)", 16 | ] 17 | 18 | onTrackListMutationListeners.push((tracklist, tracks) => { 19 | if (!CONFIG.showInTrackLists) return 20 | if (tracks.length === 0) return 21 | 22 | const hasStars = (parent: HTMLElement) => parent.getElementsByClassName("stars").length > 0 23 | 24 | const trackListHeader = getTrackListHeader(tracklist) 25 | const firstElement = trackListHeader ?? tracks[0] 26 | 27 | const [lastColIndex] = getLastCol(firstElement) 28 | const lastColOffset = hasStars(firstElement) ? 1 : 0 29 | const newTrackListColCss = customTrackListColCss[lastColIndex - lastColOffset] 30 | 31 | if (!newTrackListColCss) return 32 | if (trackListHeader) { 33 | trackListHeader.style.gridTemplateColumns = newTrackListColCss 34 | } 35 | 36 | tracks.map(track => { 37 | if (hasStars(track)) return 38 | 39 | let addedColumnWrapper: HTMLDivElement | null = track.querySelector("div.ratings-column-wrapper") 40 | if (!addedColumnWrapper) { 41 | const [colIndex, lastColumn] = getLastCol(track) 42 | 43 | lastColumn?.setAttribute("aria-colindex", String(colIndex + 1)) 44 | 45 | addedColumnWrapper = document.createElement("div") 46 | addedColumnWrapper.setAttribute("aria-colindex", String(colIndex)) 47 | addedColumnWrapper.role = "gridcell" 48 | addedColumnWrapper.style.display = "flex" 49 | addedColumnWrapper.classList.add("ratings-column-wrapper", "main-trackList-rowSectionVariable") 50 | track.insertBefore(addedColumnWrapper, lastColumn) 51 | track.style.gridTemplateColumns = newTrackListColCss! 52 | 53 | const trackUri = track.props.uri 54 | 55 | render(html``, addedColumnWrapper) 56 | } 57 | }) 58 | }) 59 | const TRACK_PLAYLISTS = await getTracksPlaylists() 60 | -------------------------------------------------------------------------------- /extensions/star-ratings/assets/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Rate your songs with stars! And show aggregated Show album/playlist/artist stars. 3 | authors: 4 | - name: Delusoire 5 | url: https://github.com/Delusoire 6 | tags: 7 | - like 8 | - star 9 | - rate 10 | --- 11 | -------------------------------------------------------------------------------- /extensions/star-ratings/assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/star-ratings/assets/preview.png -------------------------------------------------------------------------------- /extensions/star-ratings/components.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, css, html } from "https://esm.sh/lit" 2 | import { customElement, property } from "https://esm.sh/lit/decorators.js" 3 | import { map } from "https://esm.sh/lit/directives/map.js" 4 | 5 | import { Playlist } from "./util.ts" 6 | 7 | declare global { 8 | interface HTMLElementTagNameMap { 9 | ["label-container"]: LabelContainer 10 | ["label-wrapper"]: LabelWrapper 11 | } 12 | } 13 | 14 | @customElement("label-container") 15 | class LabelContainer extends LitElement { 16 | static styles = css` 17 | .labels-container { 18 | height: var(--row-height); 19 | align-items: center; 20 | display: flex; 21 | overflow: hidden; 22 | gap: 5px; 23 | } 24 | ` 25 | 26 | @property({ type: Array }) 27 | playlists = new Array() 28 | 29 | render() { 30 | return html`
31 | ${map(this.playlists, playlist => html``)} 32 |
` 33 | } 34 | } 35 | 36 | @customElement("label-wrapper") 37 | class LabelWrapper extends LitElement { 38 | static styles = css` 39 | :host { 40 | position: relative; 41 | height: 24px; 42 | } 43 | img { 44 | width: 24px; 45 | height: 100%; 46 | object-fit: cover; 47 | border-radius: 2px; 48 | } 49 | ` 50 | 51 | @property() 52 | playlist = {} as Playlist 53 | 54 | render() { 55 | return html`` 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /extensions/star-ratings/settings.ts: -------------------------------------------------------------------------------- 1 | import { SettingsSection } from "../../shared/settings.tsx" 2 | 3 | const settings = new SettingsSection("Star Ratings").addToggle({ id: "showInTrackLists", desc: "Show in tracklists" }) 4 | 5 | settings.pushSettings() 6 | 7 | export const CONFIG = settings.toObject() as { 8 | showInTrackLists: boolean 9 | } 10 | -------------------------------------------------------------------------------- /extensions/star-ratings/util.ts: -------------------------------------------------------------------------------- 1 | import { _ } from "../../shared/deps.ts" 2 | import { fetchRootFolder } from "../../shared/platformApi.ts" 3 | 4 | export type Playlist = Spicetify.Platform.RootlistAPI.Playlist 5 | export type Folder = Spicetify.Platform.RootlistAPI.Folder 6 | 7 | const { PlaylistAPI } = Spicetify.Platform 8 | 9 | export const getTrackListHeader = (trackList: HTMLDivElement) => 10 | trackList.querySelector(".main-trackList-trackListHeader")?.firstChild as HTMLDivElement 11 | 12 | export const getLastCol = (parent: HTMLElement) => { 13 | const lastCol = parent.querySelector("div.main-trackList-rowSectionEnd")! 14 | const lastColIndex = Number(lastCol.getAttribute("aria-colindex")) 15 | return [lastColIndex, lastCol] as [number, HTMLDivElement] 16 | } 17 | 18 | export const getOwnedPlaylists = async () => { 19 | const rootFolder = await fetchRootFolder() 20 | 21 | const traverse = (item: Folder | Playlist): Playlist[] => { 22 | switch (item.type) { 23 | case "folder": 24 | return item.items.flatMap(traverse) 25 | case "playlist": 26 | return item.isOwnedBySelf ? [item] : [] 27 | } 28 | } 29 | 30 | return traverse(rootFolder) 31 | } 32 | 33 | export const getTracksPlaylists = async () => { 34 | const ownedPlaylists = await getOwnedPlaylists() 35 | const tracks = await Promise.all(ownedPlaylists.map(playlist => PlaylistAPI.getContents(playlist.uri))) 36 | const [playlists, uris] = _.unzip( 37 | tracks.flatMap((tracks, i) => tracks.items.map(track => [ownedPlaylists[i], track.uri] as const)), 38 | ) as [Playlist[], string[]] 39 | 40 | return Object.groupBy(playlists, (_, i) => uris[i]) as Record 41 | } 42 | -------------------------------------------------------------------------------- /extensions/vaultify/app.ts: -------------------------------------------------------------------------------- 1 | import { fetchRootFolder } from "../../shared/platformApi.ts" 2 | 3 | import { 4 | LibraryBackup, 5 | LocalStorageBackup, 6 | SettingBackup, 7 | extractLikedPlaylistTreeRecur, 8 | getLibraryAlbumUris, 9 | getLibraryArtistUris, 10 | getLibraryTrackUris, 11 | getLocalStorage, 12 | getLocalStoreAPI, 13 | getSettings, 14 | } from "./backup.ts" 15 | import { restoreExtensions, restoreLibrary, restoreSettings } from "./restore.ts" 16 | 17 | const { ClipboardAPI } = Spicetify.Platform 18 | 19 | export const backup = async (silent = false) => { 20 | const libraryTracks = await getLibraryTrackUris() 21 | const libraryAlbums = await getLibraryAlbumUris() 22 | const libraryArtists = await getLibraryArtistUris() 23 | const playlists = await fetchRootFolder().then(extractLikedPlaylistTreeRecur) 24 | const localStore = getLocalStorage() 25 | const localStoreAPI = getLocalStoreAPI() 26 | 27 | const settings = getSettings() 28 | 29 | await ClipboardAPI.copy( 30 | JSON.stringify({ 31 | libraryTracks, 32 | libraryAlbums, 33 | libraryArtists, 34 | playlists, 35 | localStore, 36 | localStoreAPI, 37 | settings, 38 | } as Vault), 39 | ) 40 | 41 | !silent && Spicetify.showNotification("Backed up Playlists, Extensions and Settings") 42 | } 43 | 44 | type Vault = LibraryBackup & LocalStorageBackup & SettingBackup 45 | export enum RestoreScope { 46 | LIBRARY = "library", 47 | EXTENSIONS = "extensions", 48 | SETTINGS = "settings", 49 | } 50 | 51 | export const restoreFactory = (mode: RestoreScope) => async () => { 52 | const vault = JSON.parse(await ClipboardAPI.paste()) as Vault 53 | 54 | switch (mode) { 55 | case RestoreScope.LIBRARY: 56 | return restoreLibrary(vault, true) 57 | case RestoreScope.EXTENSIONS: 58 | return restoreExtensions(vault, true) 59 | case RestoreScope.SETTINGS: 60 | return restoreSettings(vault, true) 61 | } 62 | } 63 | 64 | import("./settings.ts") 65 | -------------------------------------------------------------------------------- /extensions/vaultify/assets/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Vault for your playlists and settings (Backup/Restore) 3 | authors: 4 | - name: Delusoire 5 | url: https://github.com/Delusoire 6 | - name: Tetrax-10 7 | tags: 8 | - backup 9 | - restore 10 | - setting 11 | - extension 12 | --- 13 | -------------------------------------------------------------------------------- /extensions/vaultify/assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Delusoire/spicetify-extensions/1d109fa00a09919d6040c2ac384a7673cb02bdd7/extensions/vaultify/assets/preview.png -------------------------------------------------------------------------------- /extensions/vaultify/backup.ts: -------------------------------------------------------------------------------- 1 | import { _ } from "../../shared/deps.ts" 2 | import { fetchPlaylistContents } from "../../shared/platformApi.ts" 3 | 4 | import { LikedPlaylist, PersonalFolder, PersonalPlaylist, PoF, extractUrisWrapper } from "./util.ts" 5 | 6 | const { LibraryAPI, LocalStorageAPI } = Spicetify.Platform 7 | 8 | export type LibraryBackup = { 9 | libraryTracks: Array 10 | libraryAlbums: Array 11 | libraryArtists: Array 12 | playlists: PersonalFolder 13 | } 14 | export type LocalStorageBackup = { 15 | localStore: Array<[string, string]> 16 | localStoreAPI: Array<[string, string]> 17 | } 18 | 19 | export type SettingBackup = { 20 | settings: Array<[string, string, any]> 21 | } 22 | 23 | const getLibraryTracks = () => 24 | LibraryAPI.getTracks({ 25 | limit: -1, 26 | sort: { field: "ADDED_AT", order: "ASC" }, 27 | }) 28 | 29 | const getLibraryAlbums = () => 30 | LibraryAPI.getAlbums({ 31 | limit: 2 ** 30, 32 | sort: { field: "ADDED_AT" }, 33 | }) 34 | 35 | const getLibraryArtists = () => 36 | LibraryAPI.getArtists({ 37 | limit: 2 ** 30, 38 | sort: { 39 | field: "ADDED_AT", 40 | }, 41 | }) 42 | 43 | export const getLibraryTrackUris = extractUrisWrapper(getLibraryTracks) 44 | export const getLibraryAlbumUris = extractUrisWrapper(getLibraryAlbums) 45 | export const getLibraryArtistUris = extractUrisWrapper(getLibraryArtists) 46 | 47 | enum SettingType { 48 | CHECKBOX = "checkbox", 49 | TEXT = "text", 50 | SELECT = "select", 51 | } 52 | 53 | type Setting = 54 | | [string, SettingType.CHECKBOX, boolean] 55 | | [string, SettingType.TEXT, string] 56 | | [string, SettingType.SELECT, string] 57 | export const getSettings = () => { 58 | const SETTINGS_EL_SEL = `[id^="settings."],[id^="desktop."],[class^="network."]` 59 | const settingsEls = Array.from(document.querySelectorAll(SETTINGS_EL_SEL) as NodeListOf) 60 | const settings = settingsEls.map(settingEl => { 61 | const id = settingEl.getAttribute("id") 62 | 63 | if (!id) return null 64 | 65 | if (settingEl instanceof HTMLInputElement) { 66 | switch (settingEl.getAttribute("type")) { 67 | case "checkbox": 68 | return [id, SettingType.CHECKBOX, settingEl.checked] as const 69 | case "text": 70 | return [id, SettingType.TEXT, settingEl.value] as const 71 | } 72 | } else if (settingEl instanceof HTMLSelectElement) { 73 | return [id, SettingType.SELECT, settingEl.value] as const 74 | } 75 | return null 76 | }) 77 | return _.compact(settings) as Setting[] 78 | } 79 | 80 | export const getLocalStorage = () => { 81 | const LS_ALLOW_REGEX = /^(?:marketplace:)|(?:extensions:)|(?:spicetify)/ 82 | return Object.entries(localStorage).filter(([key]) => LS_ALLOW_REGEX.test(key)) 83 | } 84 | 85 | export const getLocalStoreAPI = () => { 86 | return Object.entries(LocalStorageAPI.items) 87 | .filter(([key]) => key.startsWith(LocalStorageAPI.namespace)) 88 | .map(([key, value]) => [key.split(":")[1], value] as const) 89 | } 90 | 91 | export const extractLikedPlaylistTreeRecur = async ( 92 | leaf: PoF, 93 | ): Promise => { 94 | switch (leaf.type) { 95 | case "playlist": { 96 | const getPlaylistContents = (uri: string) => 97 | fetchPlaylistContents(uri).then(tracks => tracks.map(track => track.uri)) 98 | 99 | return { 100 | [leaf.name]: leaf.isOwnedBySelf ? await getPlaylistContents(leaf.uri) : leaf.uri, 101 | } as PersonalPlaylist | LikedPlaylist 102 | } 103 | case "folder": { 104 | const a = leaf.items.map(extractLikedPlaylistTreeRecur) 105 | return { 106 | [leaf.name]: await Promise.all(a), 107 | } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /extensions/vaultify/restore.ts: -------------------------------------------------------------------------------- 1 | import { addPlaylist, createFolder, createPlaylistFromTracks, setTracksLiked } from "../../shared/platformApi.ts" 2 | import { REACT_PROPS, SpotifyLoc } from "../../shared/util.ts" 3 | 4 | import { LibraryBackup, LocalStorageBackup, SettingBackup } from "./backup.ts" 5 | import { LikedPlaylist, PersonalFolder, PersonalPlaylist, isContentOfPersonalPlaylist } from "./util.ts" 6 | 7 | const { LocalStorageAPI } = Spicetify.Platform 8 | 9 | export const restoreLibrary = async (data: LibraryBackup, silent = true) => { 10 | setTracksLiked(data.libraryTracks, true) 11 | setTracksLiked(data.libraryAlbums, true) 12 | setTracksLiked(data.libraryArtists, true) 13 | await restorePlaylistseRecur(data.playlists) 14 | !silent && Spicetify.showNotification("Restored Library") 15 | } 16 | 17 | export const restoreExtensions = (vault: LocalStorageBackup, silent = true) => { 18 | vault.localStore.forEach(([k, v]) => localStorage.setItem(k, v)) 19 | vault.localStoreAPI.forEach(([k, v]) => LocalStorageAPI.setItem(k, v)) 20 | !silent && Spicetify.showNotification("Restored Extensions") 21 | } 22 | 23 | export const restoreSettings = (data: SettingBackup, silent = true) => { 24 | data.settings.map(([id, type, value]) => { 25 | const setting = document.querySelector(`[id="${id}"]`) 26 | if (!setting) return console.warn(`Setting for ${id} wasn't found`) 27 | 28 | if (type === "text") setting.value = value 29 | else if (type === "checkbox") setting.checked = value 30 | else if (type === "select") setting.value = value 31 | else return 32 | 33 | const settingReactProps = setting[REACT_PROPS] 34 | settingReactProps.onChange({ target: setting }) 35 | }) 36 | !silent && Spicetify.showNotification("Restored Settings") 37 | } 38 | 39 | const restorePlaylistseRecur = async (leaf: PersonalFolder | PersonalPlaylist | LikedPlaylist, folder = "") => 40 | await Promise.all( 41 | Object.keys(leaf).map(async name => { 42 | const subleaf = leaf[name] 43 | 44 | // isPlaylist 45 | if (!Array.isArray(subleaf)) return void addPlaylist(subleaf, folder) 46 | if (subleaf.length === 0) return 47 | 48 | //isCollectionOfTracks 49 | if (isContentOfPersonalPlaylist(subleaf)) return void createPlaylistFromTracks(name, subleaf, folder) 50 | 51 | //isFolder 52 | const { success, uri } = await createFolder(name, SpotifyLoc.after.fromUri(folder)) 53 | if (!success) return 54 | 55 | subleaf.forEach(leaf => restorePlaylistseRecur(leaf, uri)) 56 | }), 57 | ) 58 | -------------------------------------------------------------------------------- /extensions/vaultify/settings.ts: -------------------------------------------------------------------------------- 1 | import { SettingsSection } from "../../shared/settings.tsx" 2 | 3 | import { RestoreScope, backup, restoreFactory } from "./app.ts" 4 | 5 | const settings = new SettingsSection("Vaultify") 6 | .addButton({ 7 | id: "backup", 8 | desc: "Backup Library, Extensions and Settings", 9 | text: "Backup to clipboard", 10 | onClick: backup, 11 | }) 12 | .addButton({ 13 | id: "restoreLibrary", 14 | desc: "Restore Library", 15 | text: "Restore from clipboard", 16 | onClick: restoreFactory(RestoreScope.LIBRARY), 17 | }) 18 | .addButton({ 19 | id: "restoreExtensions", 20 | desc: "Restore Extensions", 21 | text: "Restore from clipboard", 22 | onClick: restoreFactory(RestoreScope.EXTENSIONS), 23 | }) 24 | .addButton({ 25 | id: "restoreSettings", 26 | desc: "Restore Settings", 27 | text: "Restore from clipboard", 28 | onClick: restoreFactory(RestoreScope.SETTINGS), 29 | }) 30 | 31 | settings.pushSettings() 32 | -------------------------------------------------------------------------------- /extensions/vaultify/util.ts: -------------------------------------------------------------------------------- 1 | const { URI } = Spicetify 2 | 3 | type PagedItem = Spicetify.Platform.LibraryAPI.Paged<{ uri: string }> 4 | type Task = () => Promise 5 | export const extractUrisWrapper = (fetcher: Task) => () => 6 | fetcher().then(({ items }) => items.map(item => item.uri)) 7 | 8 | export type PoF = Playlist | Folder 9 | 10 | export interface Playlist { 11 | type: "playlist" 12 | name: string 13 | isOwnedBySelf: boolean 14 | uri: string 15 | } 16 | 17 | export interface Folder { 18 | type: "folder" 19 | name: string 20 | items: PoF[] 21 | } 22 | 23 | type SpotifyTrackUri = string & { _: "track" } 24 | type SpotifyPlaylistUri = string & { _: "playlist" } 25 | 26 | type namedProp = Record 27 | export type LikedPlaylist = namedProp 28 | export type PersonalPlaylist = namedProp 29 | export type PersonalFolder = namedProp> 30 | 31 | export const isContentOfPersonalPlaylist = ( 32 | subleaf: PersonalFolder[""] | PersonalPlaylist[""], 33 | ): subleaf is PersonalPlaylist[""] => typeof subleaf[0] === "string" && URI.isTrack(subleaf[0]) 34 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | import { TrackData } from "./shared/parse.ts" 2 | 3 | declare global { 4 | let lastSortedQueue: TrackData[] 5 | let tracksRatings: Record 6 | } 7 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | [{"description":"Bad lyrics, Beautfil Lyrics but worse. At least it doesn't take your cpu hostage? Credits; This extension started as a Beautiful Lyrics clone, and become worse over time.","authors":[{"name":"Delusoire","url":"https://github.com/Delusoire"}],"tags":["lyrics","bad"],"name":"bad-lyrics","preview":"extensions\\bad-lyrics\\assets\\preview.png","main":"dist\\bad-lyrics\\prism.mjs","readme":"extensions\\bad-lyrics\\assets\\README.md"},{"name":"corbeille","preview":"extensions\\corbeille\\assets\\preview.png","main":"dist\\corbeille\\prism.mjs","readme":"extensions\\corbeille\\assets\\README.md"},{"description":"Placeholder","authors":[{"name":"Delusoire","url":"https://github.com/Delusoire"}],"tags":["isrc"],"name":"detect-duplicates","preview":"extensions\\detect-duplicates\\assets\\preview.png","main":"dist\\detect-duplicates\\prism.mjs","readme":"extensions\\detect-duplicates\\assets\\README.md"},{"description":"Vim-like keyboard shortcuts for spotify. Now with sneak mode.","authors":[{"name":"Delusoire","url":"https://github.com/Delusoire"},{"name":"khanhas"},{"name":"Tetrax-10"}],"tags":["keybind","keyboard","sneak","vim"],"name":"keyboard-shortcuts","preview":"extensions\\keyboard-shortcuts\\assets\\preview.png","main":"dist\\keyboard-shortcuts\\prism.mjs","readme":"extensions\\keyboard-shortcuts\\assets\\README.md"},{"description":"Play your playlists and others' with enhanced songs","authors":[{"name":"Delusoire","url":"https://github.com/Delusoire"},{"name":"khanhas"},{"name":"Tetrax-10"}],"tags":["enhanced"],"name":"play-enhanced-songs","preview":"extensions\\play-enhanced-songs\\assets\\preview.png","main":"dist\\play-enhanced-songs\\prism.mjs","readme":"extensions\\play-enhanced-songs\\assets\\README.md"},{"description":"Search/Play the music video of the selected track on YouTube. Setting your own API Key allows you to bypass the search and directly open the first result.","authors":[{"name":"Delusoire","url":"https://github.com/Delusoire"},{"name":"Tetrax-10"}],"tags":["search","youtube"],"name":"search-on-youtube","preview":"extensions\\search-on-youtube\\assets\\preview.png","main":"dist\\search-on-youtube\\prism.mjs","readme":"extensions\\search-on-youtube\\assets\\README.md"},{"description":"Show the genres for the currently playing track and on artists' pages. This extensions requires you to use your own LastFM API Key.","authors":[{"name":"Delusoire","url":"https://github.com/Delusoire"},{"name":"Tetrax-10"}],"tags":["genres","lastfm"],"name":"show-the-genres","preview":"extensions\\show-the-genres\\assets\\preview.png","main":"dist\\show-the-genres\\prism.mjs","readme":"extensions\\show-the-genres\\assets\\README.md"},{"description":"Sort your Playlists/Albums/Artists/Liked Songs on a bunch of metrics.","authors":[{"name":"Delusoire","url":"https://github.com/Delusoire"},{"name":"Tetrax-10"}],"tags":["sort","playcount","lastfm","shuffle"],"name":"sort-plus","preview":"extensions\\sort-plus\\assets\\preview.png","main":"dist\\sort-plus\\prism.mjs","readme":"extensions\\sort-plus\\assets\\README.md"},{"description":"Create anonymized radio-playlists for your favorite songs, playlists, artists and albums.","authors":[{"name":"Delusoire","url":"https://github.com/Delusoire"},{"name":"BitesizedLion"}],"tags":["radio","spoqify"],"name":"spoqify-radios","preview":"extensions\\spoqify-radios\\assets\\preview.png","main":"dist\\spoqify-radios\\prism.mjs","readme":"extensions\\spoqify-radios\\assets\\README.md"},{"description":"Rate your songs with stars! And show aggregated Show album/playlist/artist stars.","authors":[{"name":"Delusoire","url":"https://github.com/Delusoire"}],"tags":["like","star","rate"],"name":"star-ratings","preview":"extensions\\star-ratings\\assets\\preview.png","main":"dist\\star-ratings\\prism.mjs","readme":"extensions\\star-ratings\\assets\\README.md"},{"description":"Rate your songs with stars! And show aggregated Show album/playlist/artist stars.","authors":[{"name":"Delusoire","url":"https://github.com/Delusoire"}],"tags":["like","star","rate"],"name":"star-ratings-2","preview":"extensions\\star-ratings-2\\assets\\preview.png","main":"dist\\star-ratings-2\\prism.mjs","readme":"extensions\\star-ratings-2\\assets\\README.md"},{"description":"Vault for your playlists and settings (Backup/Restore)","authors":[{"name":"Delusoire","url":"https://github.com/Delusoire"},{"name":"Tetrax-10"}],"tags":["backup","restore","setting","extension"],"name":"vaultify","preview":"extensions\\vaultify\\assets\\preview.png","main":"dist\\vaultify\\prism.mjs","readme":"extensions\\vaultify\\assets\\README.md"}] -------------------------------------------------------------------------------- /shared/GraphQL/fetchAlbum.ts: -------------------------------------------------------------------------------- 1 | import { _ } from "../deps.ts" 2 | import { Items, ItemsWithCount } from "./sharedTypes.ts" 3 | 4 | const { Locale, GraphQL } = Spicetify 5 | 6 | export type fetchAlbumRes = { 7 | __typename: "album" 8 | uri: string 9 | name: string 10 | artists: { 11 | totalCount: number 12 | items: Array<{ 13 | id: string 14 | uri: string 15 | profile: { 16 | name: string 17 | } 18 | visuals: { 19 | avatarImage: { 20 | sources: Array 21 | } 22 | } 23 | sharingInfo: { 24 | shareUrl: string 25 | } 26 | }> 27 | } 28 | coverArt: { 29 | extractedColors: { 30 | colorRaw: { 31 | hex: string 32 | } 33 | colorLight: { 34 | hex: string 35 | } 36 | colorDark: { 37 | hex: string 38 | } 39 | } 40 | sources: Array 41 | } 42 | discs: { 43 | totalCount: number 44 | items: Array<{ 45 | number: number 46 | tracks: { 47 | totalCount: number 48 | } 49 | }> 50 | } 51 | releases: ItemsWithCount<{ 52 | uri: string 53 | name: string 54 | }> 55 | type: string 56 | date: { 57 | isoString: string 58 | precision: string 59 | } 60 | playability: { 61 | playable: boolean 62 | reason: string 63 | } 64 | label: string 65 | copyright: { 66 | totalCount: number 67 | items: Array<{ 68 | type: string 69 | text: string 70 | }> 71 | } 72 | courtesyLine: string 73 | saved: boolean 74 | sharingInfo: { 75 | shareUrl: string 76 | shareId: string 77 | } 78 | tracks: ItemsWithCount<{ 79 | uid: string 80 | track: { 81 | saved: boolean 82 | uri: string 83 | name: string 84 | playcount: string 85 | discNumber: number 86 | trackNumber: number 87 | contentRating: { 88 | label: string 89 | } 90 | relinkingInformation: any 91 | duration: { 92 | totalMilliseconds: number 93 | } 94 | playability: { 95 | playable: boolean 96 | } 97 | artists: Items<{ 98 | uri: string 99 | profile: { 100 | name: string 101 | } 102 | }> 103 | } 104 | }> 105 | moreAlbumsByArtist: Items<{ 106 | discography: { 107 | popularReleasesAlbums: Items<{ 108 | id: string 109 | uri: string 110 | name: string 111 | date: { 112 | year: number 113 | } 114 | coverArt: { 115 | sources: Array 116 | } 117 | playability: { 118 | playable: boolean 119 | reason: string 120 | } 121 | sharingInfo: { 122 | shareId: string 123 | shareUrl: string 124 | } 125 | type: string 126 | }> 127 | } 128 | }> 129 | } 130 | const queue = new Array<() => void>() 131 | export const fetchAlbum = async (uri: string, offset = 0, limit = 415) => { 132 | let resolveOwn: undefined | (() => void) 133 | await new Promise(resolve => { 134 | queue.push((resolveOwn = resolve)) 135 | if (queue.length < 1000) { 136 | resolve() 137 | } 138 | }) 139 | 140 | const res = await GraphQL.Request(GraphQL.Definitions.getAlbum, { 141 | uri, 142 | locale: Locale.getLocale(), 143 | offset, 144 | limit, 145 | }) 146 | 147 | const index = queue.findIndex(r => r === resolveOwn) 148 | if (index != -1) { 149 | queue.splice(index, 1) 150 | } 151 | queue[0]?.() 152 | 153 | return res.data.albumUnion as fetchAlbumRes 154 | } 155 | -------------------------------------------------------------------------------- /shared/GraphQL/fetchArtistDiscography.ts: -------------------------------------------------------------------------------- 1 | import { Item2, ItemsReleases } from "./sharedTypes.ts" 2 | 3 | const { GraphQL } = Spicetify 4 | 5 | export type fetchArtistDiscographyRes = { 6 | __typename: "artist" 7 | discography: { 8 | all: ItemsReleases 9 | } 10 | } 11 | export const fetchArtistDiscography = (uri: string, offset = 0, limit = 100) => { 12 | const _fetchArtistDiscography = async (offset: number, limit: number) => { 13 | const res = await GraphQL.Request(GraphQL.Definitions.queryArtistDiscographyAll, { 14 | uri, 15 | offset, 16 | limit, 17 | }) 18 | const { discography } = res.data.artistUnion as fetchArtistDiscographyRes 19 | const { totalCount, items } = discography.all 20 | 21 | if (offset + limit < totalCount) items.push(...(await _fetchArtistDiscography(offset + limit, limit))) 22 | 23 | return items 24 | } 25 | 26 | return _fetchArtistDiscography(offset, limit) 27 | } 28 | -------------------------------------------------------------------------------- /shared/GraphQL/fetchArtistOveriew.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Item1, 3 | ItemMin, 4 | Items, 5 | ItemsReleases, 6 | ItemsReleasesWithCount, 7 | ItemsWithCount, 8 | TopTracksItem, 9 | } from "./sharedTypes.ts" 10 | 11 | const { Locale, GraphQL } = Spicetify 12 | 13 | export type fetchArtistOverviewRes = { 14 | __typename: "Artist" 15 | id: string 16 | uri: string 17 | saved: boolean 18 | stats: { 19 | followers: number 20 | monthlyListeners: number 21 | worldRank: number 22 | topCities: { 23 | items: Array<{ 24 | numberOfListeners: number 25 | city: string 26 | country: string 27 | region: string 28 | }> 29 | } 30 | } 31 | profile: { 32 | name: string 33 | verified: boolean 34 | pinnedItem: { 35 | comment: string 36 | type: string 37 | backgroundImage: { 38 | sources: Array<{ url: string }> 39 | } 40 | itemV2: {} 41 | item: { 42 | uri: string 43 | name: string 44 | images: { 45 | items: Array<{ 46 | sources: Array<{ 47 | url: string 48 | width: null 49 | height: null 50 | }> 51 | }> 52 | } 53 | } 54 | } 55 | biography: { 56 | type: string 57 | text: string 58 | } 59 | externalLinks: { 60 | items: Array<{ 61 | name: string 62 | url: string 63 | }> 64 | } 65 | playlistV2: { 66 | totalCount: number 67 | items: Array<{ 68 | data: { 69 | __typename: "Playlist" 70 | uri: string 71 | name: string 72 | description: string 73 | ownerV2: { 74 | data: { 75 | __typename: "User" 76 | name: string 77 | } 78 | } 79 | images: { 80 | items: Array<{ 81 | sources: Array 82 | }> 83 | } 84 | } 85 | }> 86 | } 87 | } 88 | visuals: { 89 | gallery: { 90 | items: Array<{ 91 | sources: Array 92 | }> 93 | } 94 | avatarImage: { 95 | sources: Array 96 | extractedColors: { 97 | colorRaw: { 98 | hex: string 99 | } 100 | } 101 | } 102 | headerImage: { 103 | sources: Array 104 | extractedColors: { 105 | colorRaw: { 106 | hex: string 107 | } 108 | } 109 | } 110 | } 111 | discography: { 112 | latest: Item1 113 | popularReleasesAlbums: ItemsWithCount 114 | singles: ItemsReleases 115 | albums: ItemsReleases 116 | compilations: ItemsReleases 117 | topTracks: Items 118 | } 119 | preRelease: any | null 120 | relatedContent: { 121 | appearsOn: ItemsReleasesWithCount< 122 | ItemMin & { 123 | artists: Items<{ 124 | uri: string 125 | profile: { 126 | name: string 127 | } 128 | }> 129 | date: { 130 | year: number 131 | } 132 | } 133 | > 134 | featuringV2: { 135 | totalCount: number 136 | items: any[] 137 | } 138 | discoveredOnV2: { 139 | totalCount: number 140 | items: any[] 141 | } 142 | relatedArtists: { 143 | totalCount: number 144 | items: any[] 145 | } 146 | } 147 | sharingInfo: { 148 | shareUrl: string 149 | shareId: string 150 | } 151 | goods: { 152 | events: { 153 | userLocation: { 154 | name: string 155 | } 156 | concerts: ItemsWithCount<{ 157 | uri: string 158 | id: string 159 | title: string 160 | category: "CONCERT" 161 | festival: boolean 162 | nearUser: boolean 163 | venue: { 164 | name: string 165 | location: { name: string } 166 | coordinates: { 167 | latitude: number 168 | longitude: number 169 | } 170 | } 171 | partnerLinks: Items<{ 172 | partnerName: string 173 | url: string 174 | }> 175 | 176 | date: Date 177 | }> & { 178 | pagingInfo: { 179 | limit: number 180 | } 181 | } 182 | } 183 | merch: Items<{ 184 | image: { 185 | sources: Array<{ url: string }> 186 | } 187 | name: string 188 | description: string 189 | price: string 190 | uri: string 191 | url: string 192 | }> 193 | } 194 | } 195 | export const fetchArtistOverview = async (uri: string) => { 196 | const res = await GraphQL.Request(GraphQL.Definitions.queryArtistOverview, { 197 | uri, 198 | locale: Locale.getLocale(), 199 | includePrerelease: true, 200 | }) 201 | 202 | return res.data.artistUnion as fetchArtistOverviewRes 203 | } 204 | -------------------------------------------------------------------------------- /shared/GraphQL/fetchArtistRelated.ts: -------------------------------------------------------------------------------- 1 | const { Locale, GraphQL } = Spicetify 2 | 3 | type fetchArtistRelatedRes = Array<{ 4 | id: string 5 | uri: string 6 | profile: { 7 | name: string 8 | } 9 | visuals: { 10 | avatarImage: { 11 | sources: Array 12 | } 13 | } 14 | }> 15 | export const fetchArtistRelated = async (uri: string) => { 16 | const res = await GraphQL.Request(GraphQL.Definitions.queryArtistRelated, { 17 | uri, 18 | locale: Locale.getLocale(), 19 | }) 20 | 21 | return res.data.artistUnion.relatedContent.relatedArtists.items as fetchArtistRelatedRes 22 | } 23 | -------------------------------------------------------------------------------- /shared/GraphQL/searchModalResults.ts: -------------------------------------------------------------------------------- 1 | import { Items } from "./sharedTypes.ts" 2 | 3 | const { GraphQL } = Spicetify 4 | 5 | type Track = { 6 | __typename: "Track" 7 | uri: string 8 | name: string 9 | albumOfTrack: { 10 | coverArt: { 11 | extractedColors: { 12 | colorDark: { 13 | hex: string 14 | isFallback: boolean 15 | } 16 | } 17 | sources: Array 18 | } 19 | } 20 | artists: Items<{ 21 | profile: { 22 | name: string 23 | } 24 | }> 25 | } 26 | 27 | type TrackResponseWrapper = { 28 | __typename: "TrackResponseWrapper" 29 | data: Track 30 | } 31 | 32 | type searchModalResultsRes = Array<{ 33 | matchedFields: string[] 34 | item: TrackResponseWrapper 35 | }> 36 | export const searchModalResults = async ( 37 | q: string, 38 | offset = 0, 39 | limit = 50, 40 | topResultsNum = 20, 41 | includeAudiobooks = true, 42 | ) => { 43 | const res = await GraphQL.Request(GraphQL.Definitions.searchModalResults, { 44 | searchTerm: q, 45 | offset, 46 | limit, 47 | numberOfTopResults: topResultsNum, 48 | includeAudiobooks, 49 | }) 50 | 51 | return res.data.searchV2.topResults.itemsV2 as searchModalResultsRes 52 | } 53 | -------------------------------------------------------------------------------- /shared/GraphQL/searchTracks.ts: -------------------------------------------------------------------------------- 1 | import { Items } from "./sharedTypes.ts" 2 | import { searchTracksDefinition } from "./Definitions/searchTracks.ts" 3 | 4 | const { GraphQL } = Spicetify 5 | 6 | type Track = { 7 | __typename: "Track" 8 | uri: string 9 | name: string 10 | albumOfTrack: { 11 | uri: string 12 | name: string 13 | coverArt: { 14 | extractedColors: { 15 | colorDark: { 16 | hex: string 17 | isFallback: boolean 18 | } 19 | } 20 | sources: Array 21 | } 22 | id: string 23 | } 24 | artists: Items<{ 25 | uri: string 26 | profile: { 27 | name: string 28 | } 29 | }> 30 | contentRating: { 31 | label: "NONE" | string 32 | } 33 | duration: { 34 | totalMilliseconds: number 35 | } 36 | playability: { 37 | playable: boolean 38 | } 39 | associations: any 40 | } 41 | 42 | type TrackResponseWrapper = { 43 | data: Track 44 | } 45 | 46 | type searchModalResultsRes = Array<{ 47 | matchedFields: string[] 48 | item: TrackResponseWrapper 49 | }> 50 | export const searchTracks = async (q: string, offset = 0, limit = 50, topResultsNum = 20, includeAudiobooks = true) => { 51 | const res = await GraphQL.Request(searchTracksDefinition, { 52 | searchTerm: q, 53 | offset, 54 | limit, 55 | numberOfTopResults: topResultsNum, 56 | includeAudiobooks, 57 | }) 58 | 59 | return res.data.searchV2.tracksV2.items as searchModalResultsRes 60 | } 61 | -------------------------------------------------------------------------------- /shared/GraphQL/sharedTypes.ts: -------------------------------------------------------------------------------- 1 | export type Items = { 2 | items: Array 3 | } 4 | export type ItemsWithCount = Items & { 5 | totalCount: number 6 | } 7 | 8 | export type ItemsReleases = ItemsWithCount<{ 9 | releases: Items 10 | }> 11 | 12 | export type ItemsReleasesWithCount = ItemsWithCount<{ 13 | releases: ItemsWithCount 14 | }> 15 | 16 | export type Date = ( 17 | | { 18 | year: number 19 | month?: number 20 | day?: number 21 | hour?: number 22 | mintue?: number 23 | second?: number 24 | precision: "YEAR" 25 | } 26 | | { 27 | year: number 28 | month: number 29 | day?: number 30 | hour?: number 31 | mintue?: number 32 | second?: number 33 | precision: "MONTH" 34 | } 35 | | { 36 | year: number 37 | month: number 38 | day: number 39 | hour?: number 40 | mintue?: number 41 | second?: number 42 | precision: "DAY" 43 | } 44 | | { 45 | year: number 46 | month: number 47 | day: number 48 | hour: number 49 | mintue?: number 50 | second?: number 51 | precision: "HOUR" 52 | } 53 | | { 54 | year: number 55 | month: number 56 | day: number 57 | hour: number 58 | mintue: number 59 | second?: number 60 | precision: "MINUTE" 61 | } 62 | | { 63 | year: number 64 | month: number 65 | day: number 66 | hour: number 67 | mintue: number 68 | second: number 69 | precision: "SECOND" 70 | } 71 | ) & { 72 | isoString: string 73 | } 74 | 75 | type Playability = { 76 | playable: boolean 77 | reason: "PLAYABLE" | string 78 | } 79 | 80 | export type ItemMin = { 81 | id: string 82 | uri: string 83 | name: string 84 | type: "SINGLE" | "ALBUM" | "COMPILATION" | string 85 | coverArt: { 86 | sources: Array 87 | } 88 | sharingInfo: { 89 | shareId: string 90 | shareUrl: string 91 | } 92 | } 93 | 94 | export type ItemBase = ItemMin & { 95 | tracks: { 96 | totalCount: number 97 | } 98 | playability: Playability 99 | } 100 | 101 | export type Item1 = ItemBase & { 102 | copyright: { 103 | items: Array<{ 104 | type: string 105 | text: string 106 | }> 107 | } 108 | date: Date 109 | 110 | label: string 111 | } 112 | 113 | export type Item2 = ItemBase & { 114 | date: { 115 | year: number 116 | isoString: string 117 | } 118 | } 119 | 120 | export type TopTracksItem = { 121 | uid: string 122 | track: { 123 | id: string 124 | uri: string 125 | name: string 126 | playcount: string 127 | discNumber: number 128 | duration: { 129 | totalMilliseconds: number 130 | } 131 | playability: Playability 132 | contentRating: { 133 | label: "NONE" | "EXPLICIT" 134 | } 135 | artists: Items<{ 136 | uri: string 137 | profile: { 138 | name: string 139 | } 140 | }> 141 | albumOfTrack: { 142 | uri: string 143 | coverArt: { 144 | sources: Array<{ url: string }> 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /shared/api.ts: -------------------------------------------------------------------------------- 1 | import { AccessToken, SpotifyApi } from "https://esm.sh/@fostertheweb/spotify-web-api-ts-sdk" 2 | 3 | import { _ } from "./deps.ts" 4 | 5 | const { CosmosAsync } = Spicetify 6 | 7 | export const spotifyApi = SpotifyApi.withAccessToken("client-id", {} as AccessToken, { 8 | // @ts-ignore 9 | fetch(url, opts) { 10 | const { method } = opts! 11 | // @ts-ignore 12 | return CosmosAsync.resolve(method, url) 13 | }, 14 | deserializer: { 15 | deserialize(res) { 16 | return (res as unknown as Spicetify.CosmosAsync.Response).body 17 | }, 18 | }, 19 | }) 20 | 21 | /* Spotify Web API */ 22 | 23 | export const fetchWebSoundOfSpotifyPlaylist = async (genre: string) => { 24 | const name = `The Sound Of ${genre}` 25 | const re = new RegExp(`^${_.escapeRegExp(name)}$`, "i") 26 | const res = await spotifyApi.search(name, ["playlist"]) 27 | const item = res.playlists.items.find(item => item?.owner.id === "thesoundsofspotify" && re.test(item.name)) 28 | return item?.uri 29 | } 30 | 31 | /* Last FM */ 32 | 33 | export interface fetchLastFMTrackResMinimal { 34 | track: { 35 | name: string 36 | mbid: string 37 | url: string 38 | duration: string 39 | listeners: string 40 | playcount: string 41 | artist: { 42 | name: string 43 | mbid: string 44 | url: string 45 | } 46 | album: { 47 | artist: string 48 | title: string 49 | mbid: string 50 | url: string 51 | } 52 | userplaycount: string 53 | userloved: string 54 | toptags: { 55 | tag: Array<{ 56 | name: string 57 | url: string 58 | }> 59 | } 60 | wiki: { 61 | published: string 62 | summary: string 63 | content: string 64 | } 65 | } 66 | } 67 | 68 | export const fetchLastFMTrack = async (LFMApiKey: string, artist: string, trackName: string, lastFmUsername = "") => { 69 | const url = new URL("https://ws.audioscrobbler.com/2.0/") 70 | url.searchParams.append("method", "track.getInfo") 71 | url.searchParams.append("api_key", LFMApiKey) 72 | url.searchParams.append("artist", artist) 73 | url.searchParams.append("track", trackName) 74 | url.searchParams.append("format", "json") 75 | url.searchParams.append("username", lastFmUsername) 76 | 77 | const res = (await fetch(url).then(res => res.json())) as fetchLastFMTrackResMinimal 78 | 79 | return res.track 80 | } 81 | 82 | /* Youtube */ 83 | 84 | export interface SearchYoutubeResMinimal { 85 | items: Array<{ 86 | id: { 87 | videoId: string 88 | } 89 | snippet: { 90 | publishedAt: string 91 | channelId: string 92 | title: string 93 | description: string 94 | channelTitle: string 95 | publishTime: string 96 | } 97 | }> 98 | } 99 | 100 | export const searchYoutube = async (YouTubeApiKey: string, searchString: string) => { 101 | const url = new URL("https://www.googleapis.com/youtube/v3/search") 102 | url.searchParams.append("part", "snippet") 103 | url.searchParams.append("maxResults", "10") 104 | url.searchParams.append("q", searchString) 105 | url.searchParams.append("type", "video") 106 | url.searchParams.append("key", YouTubeApiKey) 107 | 108 | return (await fetch(url).then(res => res.json())) as SearchYoutubeResMinimal 109 | } 110 | -------------------------------------------------------------------------------- /shared/deps.ts: -------------------------------------------------------------------------------- 1 | // @deno-types="npm:@types/lodash" 2 | import { default as ld } from "https://esm.sh/lodash" 3 | export const _ = ld 4 | 5 | // @deno-types="npm:@types/lodash/fp" 6 | import { default as ld_fp } from "https://esm.sh/lodash/fp" 7 | export const fp = ld_fp 8 | -------------------------------------------------------------------------------- /shared/fp.ts: -------------------------------------------------------------------------------- 1 | import { _ } from "./deps.ts" 2 | 3 | const { Snackbar } = Spicetify 4 | 5 | type async = { 6 | (f: (a: A) => Promise): (fa: Promise) => Promise 7 | (f: (a: A) => B): (fa: Promise) => Promise 8 | } 9 | export const pMchain: async = 10 | (f: (a: A) => R) => 11 | async (fa: A) => 12 | f(await fa) 13 | 14 | export const chunkify50 = 15 | (fn: (a: Array) => R) => 16 | async (args: Array) => { 17 | const a = await Promise.all(_.chunk(args, 50).map(fn)) 18 | return a.flat() 19 | } 20 | 21 | export const progressify = any>(f: F, n: number) => { 22 | let i = n, 23 | lastProgress = 0 24 | return async function (..._: Parameters): Promise>> { 25 | const res = (await f(...arguments)) as Awaited>, 26 | progress = Math.round((1 - --i / n) * 100) 27 | if (progress > lastProgress) { 28 | ;(Snackbar as any).updater.enqueueSetState(Snackbar, () => ({ 29 | snacks: [], 30 | queue: [], 31 | })) 32 | Snackbar.enqueueSnackbar(`Loading: ${progress}%`, { 33 | variant: "default", 34 | autoHideDuration: 200, 35 | transitionDuration: { 36 | enter: 0, 37 | exit: 0, 38 | }, 39 | }) 40 | } 41 | lastProgress = progress 42 | return res 43 | } 44 | } 45 | 46 | export type OneUplet = [E] 47 | export type TwoUplet = [E, E] 48 | export type Triplet = [E, E, E] 49 | export type Quadruplet = [E, E, E, E] 50 | export const zip_n_uplets = 51 | (n: number) => 52 | (a: A[]) => 53 | a.map((_, i, a) => a.slice(i, i + n)).slice(0, 1 - n) as R[] 54 | -------------------------------------------------------------------------------- /shared/listeners.ts: -------------------------------------------------------------------------------- 1 | import { getTrackListTracks, getTrackLists } from "../extensions/star-ratings-2/util.ts" 2 | import { _ } from "./deps.ts" 3 | 4 | import { PermanentMutationObserver, REACT_FIBER } from "./util.ts" 5 | 6 | const { Player, URI } = Spicetify 7 | const { PlayerAPI, History } = Spicetify.Platform 8 | 9 | export const onHistoryChanged = ( 10 | toMatchTo: string | RegExp | ((location: string) => boolean), 11 | callback: (uri: string) => void, 12 | dropDuplicates = true, 13 | ) => { 14 | const createMatchFn = (toMatchTo: string | RegExp | ((input: string) => boolean)) => { 15 | switch (typeof toMatchTo) { 16 | case "string": 17 | return (input: string) => input?.startsWith(toMatchTo) ?? false 18 | 19 | case "function": 20 | return toMatchTo 21 | 22 | default: 23 | return (input: string) => toMatchTo.test(input) 24 | } 25 | } 26 | 27 | let lastPathname = "" 28 | const matchFn = createMatchFn(toMatchTo) 29 | 30 | const historyChanged = ({ pathname }: any) => { 31 | if (matchFn(pathname)) { 32 | if (dropDuplicates && lastPathname === pathname) { 33 | } else callback(URI.fromString(pathname).toURI()) 34 | } 35 | lastPathname = pathname 36 | } 37 | 38 | historyChanged(History.location ?? {}) 39 | return History.listen(historyChanged) 40 | } 41 | 42 | export const onSongChanged = (callback: (state: Spicetify.Platform.PlayerAPI.PlayerState) => void) => { 43 | callback(PlayerAPI._state) 44 | Player.addEventListener("songchange", event => callback(event!.data)) 45 | } 46 | 47 | export const onPlayedPaused = (callback: (state: Spicetify.Platform.PlayerAPI.PlayerState) => void) => { 48 | callback(PlayerAPI._state) 49 | Player.addEventListener("onplaypause", event => callback(event!.data)) 50 | } 51 | 52 | const PRESENTATION_KEY = Symbol("presentation") 53 | 54 | type TrackListElement = HTMLDivElement & { [PRESENTATION_KEY]?: HTMLDivElement } 55 | type TrackElement = HTMLDivElement & { props?: Record } 56 | 57 | type TrackListMutationListener = (trackList: Required, tracks: Array>) => void 58 | export const onTrackListMutationListeners = new Array() 59 | 60 | const _onTrackListMutation = ( 61 | trackList: Required, 62 | record: MutationRecord[], 63 | observer: MutationObserver, 64 | ) => { 65 | const tracks = getTrackListTracks(trackList[PRESENTATION_KEY]) as Array> 66 | 67 | const reactFiber = trackList[PRESENTATION_KEY][REACT_FIBER].alternate 68 | const reactTracks = reactFiber.pendingProps.children as any[] 69 | const tracksProps = reactTracks.map((child: any) => child.props as Record) 70 | 71 | tracks.forEach((track, i) => (track.props = tracksProps[i])) 72 | 73 | const fullyRenderedTracks = tracks.filter(track => track.props?.uri) 74 | 75 | onTrackListMutationListeners.map(listener => listener(trackList, fullyRenderedTracks)) 76 | } 77 | 78 | new PermanentMutationObserver("main", () => { 79 | const trackLists = getTrackLists() as Array 80 | trackLists 81 | .filter(trackList => !trackList[PRESENTATION_KEY]) 82 | .forEach(trackList => { 83 | trackList[PRESENTATION_KEY] = trackList.lastElementChild!.firstElementChild! 84 | .nextElementSibling! as HTMLDivElement 85 | 86 | new MutationObserver((record, observer) => 87 | _onTrackListMutation(trackList as Required, record, observer), 88 | ).observe(trackList[PRESENTATION_KEY], { childList: true }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /shared/lscache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * lscache library 3 | * Copyright (c) 2011, Pamela Fox 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import { _ } from "./deps.ts" 19 | 20 | // Prefix for all lscache keys 21 | const CACHE_PREFIX = "lscache-" 22 | 23 | // Suffix for the key name on the expiration items in localStorage 24 | const CACHE_SUFFIX = "-cacheexpiration" 25 | 26 | // expiration date radix (set to Base-36 for most space savings) 27 | const EXPIRY_RADIX = 10 28 | 29 | // time resolution in milliseconds 30 | const expiryMilliseconds = 60 * 1000 31 | // ECMAScript max Date (epoch + 1e8 days) 32 | const maxDate = Math.floor(8.64e15 / expiryMilliseconds) 33 | 34 | let cacheBucket = "" 35 | const warnings = true 36 | 37 | function escapeRegExpSpecialCharacters(text: string) { 38 | return text.replace(/[[\]{}()*+?.\\^$|]/g, "\\$&") 39 | } 40 | 41 | function expirationKey(key: string) { 42 | return key + CACHE_SUFFIX 43 | } 44 | 45 | function currentTime() { 46 | return Math.floor(new Date().getTime() / expiryMilliseconds) 47 | } 48 | 49 | /** 50 | * Wrapper functions for localStorage methods 51 | */ 52 | 53 | function getItem(key: string) { 54 | return localStorage.getItem(CACHE_PREFIX + cacheBucket + key) 55 | } 56 | 57 | function setItem(key: string, value: any) { 58 | localStorage.setItem(CACHE_PREFIX + cacheBucket + key, value) 59 | } 60 | 61 | function removeItem(key: string) { 62 | localStorage.removeItem(CACHE_PREFIX + cacheBucket + key) 63 | } 64 | 65 | function eachKey(fn: (key: string) => void) { 66 | const prefixRegExp = new RegExp( 67 | `^${CACHE_PREFIX}${escapeRegExpSpecialCharacters(cacheBucket)}(.*)(? key.match(prefixRegExp)?.[1])) as Array 71 | keys.forEach(fn) 72 | } 73 | 74 | function flushItem(key: string) { 75 | const exprKey = expirationKey(key) 76 | removeItem(key) 77 | removeItem(exprKey) 78 | } 79 | 80 | function flushExpiredItem(key: string) { 81 | const exprKey = expirationKey(key) 82 | const expr = getItem(exprKey) 83 | 84 | if (expr) { 85 | const expirationTime = parseInt(expr, EXPIRY_RADIX) 86 | 87 | if (currentTime() >= expirationTime) { 88 | flushItem(key) 89 | return true 90 | } 91 | } 92 | 93 | return false 94 | } 95 | 96 | function warn(message: string, err?: Error) { 97 | if (!warnings) return 98 | if (!("console" in window) || typeof window.console.warn !== "function") return 99 | window.console.warn("lscache - " + message) 100 | if (err) window.console.warn("lscache - The error was: " + err.message) 101 | } 102 | 103 | function getLength(any: string | null) { 104 | return any?.length ?? 0 105 | } 106 | 107 | export const lscache = { 108 | set: function (key: string, value: any, time?: number, retryNumber = 0): boolean { 109 | let valueStr: string 110 | try { 111 | valueStr = JSON.stringify(value) 112 | } catch (_) { 113 | // Sometimes we can't stringify due to circular refs 114 | // in complex objects, so we won't bother storing then. 115 | return false 116 | } 117 | 118 | try { 119 | setItem(key, valueStr) 120 | } catch (e) { 121 | if ( 122 | retryNumber > 2 || 123 | !["QUOTA_EXCEEDED_ERR", "NS_ERROR_DOM_QUOTA_REACHED", "QuotaExceededError"].includes(e?.name) 124 | ) { 125 | warn("Could not add item with key '" + key + "'", e) 126 | return false 127 | } 128 | 129 | // If we exceeded the quota, then we will sort 130 | // by the expire time, and then remove the N oldest 131 | const storedKeys = new Array<{ key: string; size: number; expiration: number }>() 132 | eachKey(key => { 133 | const exprKey = expirationKey(key) 134 | const expiration = getItem(exprKey) 135 | let expirationDate 136 | if (expiration) { 137 | expirationDate = parseInt(expiration, EXPIRY_RADIX) 138 | } else { 139 | // TODO: Store date added for non-expiring items for smarter removal 140 | expirationDate = maxDate 141 | } 142 | storedKeys.push({ 143 | key: key, 144 | size: getLength(getItem(key)), 145 | expiration: expirationDate, 146 | }) 147 | }) 148 | // Sorts the keys with oldest expiration time last 149 | storedKeys.sort((a, b) => b.expiration - a.expiration) 150 | 151 | let sizeToSubstract = getLength(valueStr) 152 | while (storedKeys.length && sizeToSubstract > 0) { 153 | const storedKey = storedKeys.pop()! 154 | warn("Cache is full, removing item with key '" + storedKey.key + "'") 155 | flushItem(storedKey.key) 156 | sizeToSubstract -= storedKey.size 157 | } 158 | 159 | return this.set(key, valueStr, time) 160 | } 161 | 162 | // If a time is specified, store expiration info in localStorage 163 | if (time) { 164 | setItem(expirationKey(key), (currentTime() + time).toString(EXPIRY_RADIX)) 165 | } else { 166 | // In case they previously set a time, remove that info from localStorage. 167 | removeItem(expirationKey(key)) 168 | } 169 | return true 170 | }, 171 | 172 | get: function (key: string) { 173 | if (flushExpiredItem(key)) { 174 | return null 175 | } 176 | 177 | const value = getItem(key) 178 | if (!value) { 179 | return null 180 | } 181 | 182 | try { 183 | return JSON.parse(value) 184 | } catch (e) { 185 | return value 186 | } 187 | }, 188 | 189 | remove: function (key: string) { 190 | flushItem(key) 191 | }, 192 | 193 | /** 194 | * Flushes all lscache items and expiry markers without affecting rest of localStorage 195 | */ 196 | flush: function () { 197 | eachKey(flushItem) 198 | }, 199 | 200 | /** 201 | * Flushes expired lscache items and expiry markers without affecting rest of localStorage 202 | */ 203 | flushExpired: function () { 204 | eachKey(flushExpiredItem) 205 | }, 206 | 207 | /** 208 | * Appends CACHE_PREFIX so lscache will partition data in to different buckets. 209 | */ 210 | setBucket: function (bucket: string) { 211 | cacheBucket = bucket 212 | }, 213 | 214 | /** 215 | * Resets the string being appended to CACHE_PREFIX so lscache will use the default storage behavior. 216 | */ 217 | resetBucket: function () { 218 | cacheBucket = "" 219 | }, 220 | } 221 | -------------------------------------------------------------------------------- /shared/math.ts: -------------------------------------------------------------------------------- 1 | import { _, fp } from "./deps.ts" 2 | 3 | export type vector = number[] 4 | export type matrix = vector[] 5 | 6 | export const oppositeVector = (u: vector) => scalarMultVector(-1, u) 7 | export const vectorAddVector = (u: vector, v: vector) => _.zip(u, v).map(([uxi, vxi]) => uxi! + vxi!) 8 | export const vectorMultVector = (u: vector, v: vector) => _.zip(u, v).map(([uix, vix]) => uix! * vix!) 9 | export const vectorDotVector = (u: vector, v: vector) => fp.sum(vectorMultVector(u, v)) 10 | export const vectorSubVector = (u: vector, v: vector) => vectorAddVector(u, oppositeVector(v)) 11 | export const scalarMultVector = (x: number, u: vector) => u.map(uxi => x * uxi) 12 | export const vectorDivScalar = (u: vector, x: number) => scalarMultVector(1 / x, u) 13 | export const scalarAddVector = (x: number, u: vector) => u.map(uxi => x + uxi) 14 | export const vectorDist = (u: vector, v: vector) => Math.hypot(...vectorSubVector(v, u)) 15 | export const scalarLerp = (s: number, e: number, t: number) => s + (e - s) * t 16 | export const vectorLerp = (u: vector, v: vector, t: number) => 17 | _.zip(u, v).map(([uxi, vxi]) => scalarLerp(uxi!, vxi!, t)) 18 | export const remapScalar = (s: number, e: number, x: number) => (x - s) / (e - s) 19 | 20 | export const vectorCartesianVector = (u: vector, v: vector) => u.map(ux => v.map(vx => [ux, vx] as const)) 21 | 22 | export function matrixMultMatrix(m1: matrix, m2: matrix) { 23 | if (!m1.length !== !m2[0].length) { 24 | throw "Arguments should be compatible" 25 | } 26 | 27 | const atColumn = (m: matrix, column: number) => m.map(row => row[column]) 28 | 29 | const ijs = vectorCartesianVector(_.range(m1.length), _.range(m2[0].length)) 30 | return ijs.map(fp.map(([i, j]) => vectorDotVector(m1[i], atColumn(m2, j)))) 31 | } 32 | -------------------------------------------------------------------------------- /shared/modules.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error webpackChunkOpen is only defined in the browser 2 | const require = webpackChunkopen.push([[Symbol("Dummy chunk to extract require method")], {}, require => require]) 3 | const modules = Object.keys(require.m) 4 | .map(id => require(id)) 5 | .filter((module): module is Object => typeof module === "object") 6 | export const exportedMembers = modules.flatMap(module => Object.values(module)).filter(Boolean) 7 | export const exportedFunctions = exportedMembers.filter((module): module is Function => typeof module === "function") 8 | 9 | const exportedReactObjects = Object.groupBy(exportedMembers, x => x.$$typeof) 10 | const exportedContexts = exportedReactObjects[Symbol.for("react.context")]! 11 | const exportedForwardRefs = exportedReactObjects[Symbol.for("react.forward_ref")]! 12 | const exportedMemos = exportedReactObjects[Symbol.for("react.memo")]! 13 | 14 | const findByStrings = (modules: Array, ...filters: Array) => 15 | modules.find(f => 16 | filters 17 | .map(filter => 18 | typeof filter === "string" ? (s: string) => s.includes(filter) : (s: string) => filter.test(s), 19 | ) 20 | .every(filterFn => filterFn(f.toString())), 21 | ) 22 | 23 | export const CheckedPlaylistButtonIcon = findByStrings( 24 | exportedFunctions, 25 | "M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm11.748-1.97a.75.75 0 0 0-1.06-1.06l-4.47 4.47-1.405-1.406a.75.75 0 1 0-1.061 1.06l2.466 2.467 5.53-5.53z", 26 | ) 27 | 28 | export const Highlight = findByStrings(exportedFunctions, "hightlightClassName", "textToHighlight") 29 | 30 | export const SettingColumn = findByStrings(exportedFunctions, "setSectionFilterMatchQueryValue", "filterMatchQuery") 31 | export const SettingText = findByStrings(exportedFunctions, "textSubdued", "dangerouslySetInnerHTML") 32 | export const SettingToggle = findByStrings(exportedFunctions, "condensed", "onSelected") 33 | 34 | export const curationButtonClass = exportedMembers.find(m => m?.curationButton)!.curationButton 35 | 36 | export const rs_w = exportedForwardRefs.filter(x => x.render?.toString().includes("hasLeadingOrMedia")) 37 | -------------------------------------------------------------------------------- /shared/parse.ts: -------------------------------------------------------------------------------- 1 | import { Track } from "https://esm.sh/v135/@fostertheweb/spotify-web-api-ts-sdk/dist/mjs/types.js" 2 | import { fetchAlbumRes } from "./GraphQL/fetchAlbum.ts" 3 | import { TopTracksItem } from "./GraphQL/sharedTypes.ts" 4 | 5 | export type TrackData = { 6 | uri: string 7 | uid?: string 8 | name: string 9 | albumUri: string 10 | albumName?: string 11 | artistUris: string[] 12 | artistName: string 13 | durationMilis: number 14 | playcount?: number 15 | popularity?: number 16 | releaseDate?: number 17 | lastfmPlaycount?: number 18 | scrobbles?: number 19 | personalScrobbles?: number 20 | } 21 | 22 | export const parseTopTrackFromArtist = ({ track }: TopTracksItem) => ({ 23 | uri: track.uri, 24 | uid: undefined, 25 | name: track.name, 26 | albumUri: track.albumOfTrack.uri, 27 | albumName: undefined, 28 | artistUris: track.artists.items.map(artist => artist.uri), 29 | artistName: track.artists.items[0].profile.name, 30 | durationMilis: track.duration.totalMilliseconds, 31 | playcount: Number(track.playcount), 32 | popularity: undefined, 33 | releaseDate: undefined, 34 | }) 35 | 36 | export const parseArtistLikedTrack = (track: Spicetify.Platform.Track) => ({ 37 | uri: track.uri, 38 | uid: undefined, 39 | name: track.name, 40 | albumUri: track.album.uri, 41 | albumName: track.album.name, 42 | artistUris: track.artists.map(artist => artist.uri), 43 | artistName: track.artists[0].name, 44 | durationMilis: track.duration.milliseconds, 45 | playcount: undefined, 46 | popularity: undefined, 47 | releaseDate: undefined, 48 | }) 49 | 50 | export const parseAlbumTrack = ({ track }: fetchAlbumRes["tracks"]["items"][0]) => ({ 51 | uri: track.uri, 52 | uid: undefined, 53 | name: track.name, 54 | albumUri: "", // gets filled in later 55 | albumName: "", // gets filled in later 56 | artistUris: track.artists.items.map(artist => artist.uri), 57 | artistName: track.artists.items[0].profile.name, 58 | durationMilis: track.duration.totalMilliseconds, 59 | playcount: Number(track.playcount), 60 | popularity: undefined, 61 | releaseDate: -1, // gets filled in later 62 | }) 63 | 64 | export const parsePlaylistAPITrack = (track: Spicetify.Platform.PlaylistAPI.Track) => ({ 65 | uri: track.uri, 66 | uid: track.uid, 67 | name: track.name, 68 | albumUri: track.album.uri, 69 | albumName: track.album.name, 70 | artistUris: track.artists.map(artist => artist.uri), 71 | artistName: track.artists[0].name, 72 | durationMilis: track.duration.milliseconds, 73 | playcount: undefined, 74 | popularity: undefined, 75 | releaseDate: undefined, 76 | }) 77 | 78 | export const parseWebAPITrack = (track: Track) => ({ 79 | uri: track.uri, 80 | uid: undefined, 81 | name: track.name, 82 | albumUri: track.album.uri, 83 | albumName: track.album.name, 84 | artistUris: track.artists.map(artist => artist.uri), 85 | artistName: track.artists[0].name, 86 | durationMilis: track.duration_ms, 87 | playcount: undefined, 88 | popularity: track.popularity, 89 | releaseDate: new Date(track.album.release_date).getTime(), 90 | }) 91 | 92 | export const parseLibraryAPILikedTracks = (track: Spicetify.Platform.Track) => ({ 93 | uri: track.uri, 94 | uid: undefined, 95 | name: track.name, 96 | albumUri: track.album.uri, 97 | albumName: track.album.name, 98 | artistUris: track.artists.map(artist => artist.uri), 99 | artistName: track.artists[0].name, 100 | durationMilis: track.duration.milliseconds, 101 | playcount: undefined, 102 | popularity: undefined, 103 | releaseDate: undefined, 104 | }) 105 | -------------------------------------------------------------------------------- /shared/platformApi.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyLoc, SpotifyURI } from "./util.ts" 2 | 3 | const { CosmosAsync } = Spicetify 4 | const { LibraryAPI, PlaylistAPI, RootlistAPI, PlaylistPermissionsAPI, EnhanceAPI, LocalFilesAPI } = Spicetify.Platform 5 | 6 | export const areTracksLiked = (uris: SpotifyURI[]) => LibraryAPI.contains(...uris) 7 | 8 | export const setTracksLiked = (uris: SpotifyURI[], liked: boolean) => LibraryAPI[liked ? "add" : "remove"]({ uris }) 9 | 10 | export const toggleTracksLiked = async (uris: SpotifyURI[]) => { 11 | const liked = await areTracksLiked(uris) 12 | 13 | const urisByLiked = Object.groupBy(uris, (_, index) => (liked[index] ? "liked" : "notLiked")) 14 | 15 | const ps = [] 16 | urisByLiked.liked?.length && ps.push(setTracksLiked(urisByLiked.liked, false)) 17 | urisByLiked.notLiked?.length && ps.push(setTracksLiked(urisByLiked.notLiked, true)) 18 | 19 | return Promise.all(ps) 20 | } 21 | 22 | export const fetchLikedTracks = async () => 23 | ( 24 | await LibraryAPI.getTracks({ 25 | limit: Number.MAX_SAFE_INTEGER, 26 | }) 27 | ).items 28 | export const fetchArtistLikedTracks = async (uri: SpotifyURI, offset = 0, limit = 100) => 29 | (await LibraryAPI.getTracks({ uri, offset, limit })).items 30 | 31 | export const fetchPlaylistContents = async (uri: SpotifyURI) => (await PlaylistAPI.getContents(uri)).items 32 | 33 | export const createFolder = async (name: string, location: Spicetify.Platform.RootlistAPI.Location = {}) => 34 | await RootlistAPI.createFolder(name, location) 35 | 36 | export const addPlaylist = async (playlist: SpotifyURI, folder?: SpotifyURI) => 37 | await RootlistAPI.add([playlist], folder ? SpotifyLoc.after.fromUri(folder) : {}) 38 | 39 | /* Replaced by createPlaylistFromTracks */ 40 | export const createPlaylist = async (name: string, location: Spicetify.Platform.RootlistAPI.Location = {}) => 41 | await RootlistAPI.createPlaylist(name, location) 42 | 43 | export const createPlaylistFromTracks = (name: string, tracks: SpotifyURI[], folder?: SpotifyURI) => 44 | CosmosAsync.post("sp://core-playlist/v1/rootlist?responseFormat=protobufJson", { 45 | operation: "create", 46 | ...(folder ? { after: folder } : {}), 47 | name, 48 | playlist: true, 49 | uris: tracks, 50 | }) 51 | 52 | export const setPlaylistVisibility = async (playlist: SpotifyURI, visibleForAll: boolean) => 53 | await PlaylistPermissionsAPI.setBasePermission(playlist, visibleForAll ? "VIEWER" : "BLOCKED") 54 | export const setPlaylistPublished = async (playlist: SpotifyURI, published: boolean) => 55 | await RootlistAPI.setPublishedState(playlist, published) 56 | 57 | export const fetchFolder = async (folder?: SpotifyURI) => await RootlistAPI.getContents({ folderUri: folder }) 58 | export const fetchRootFolder = () => fetchFolder(undefined) 59 | 60 | export const addPlaylistTracks = async ( 61 | playlist: SpotifyURI, 62 | tracks: SpotifyURI[], 63 | location: Spicetify.Platform.RootlistAPI.Location = {}, 64 | ) => await PlaylistAPI.add(playlist, tracks, location) 65 | 66 | export const movePlaylistTracks = async ( 67 | playlist: SpotifyURI, 68 | uids: string[], 69 | location: Spicetify.Platform.RootlistAPI.Location = {}, 70 | ) => 71 | await PlaylistAPI.move( 72 | playlist, 73 | uids.map(uid => ({ uid })), 74 | location, 75 | ) 76 | 77 | export const removePlaylistTracks = (playlist: SpotifyURI, tracks: Array<{ uid: string }>) => 78 | PlaylistAPI.remove(playlist, tracks) 79 | 80 | export const fetchPlaylistEnhancedSongs300 = async (uri: SpotifyURI, offset = 0, limit = 300) => 81 | (await EnhanceAPI.getPage(uri, /* iteration */ 0, /* sessionId */ 0, offset, limit)).enhancePage.pageItems 82 | export const fetchPlaylistEnhancedSongs = async ( 83 | uri: SpotifyURI, 84 | offset = 0, 85 | ): Promise> => { 86 | const nextPageItems = await fetchPlaylistEnhancedSongs300(uri, offset) 87 | if (nextPageItems?.length < 300) return nextPageItems 88 | else return nextPageItems.concat(await fetchPlaylistEnhancedSongs(uri, offset + 300)) 89 | } 90 | 91 | export const fetchLocalTracks = async () => await LocalFilesAPI.getTracks() 92 | -------------------------------------------------------------------------------- /shared/settings.tsx: -------------------------------------------------------------------------------- 1 | import { SettingColumn, SettingText, SettingToggle } from "./modules.ts" 2 | import { _ } from "./deps.ts" 3 | 4 | type Task = (() => Awaited) | (() => Promise>) 5 | 6 | const { React, LocalStorage } = Spicetify 7 | const { ButtonSecondary } = Spicetify.ReactComponent 8 | 9 | type FieldToProps = Omit 10 | 11 | export enum FieldType { 12 | BUTTON = "button", 13 | TOGGLE = "toggle", 14 | INPUT = "input", 15 | HIDDEN = "hidden", 16 | } 17 | 18 | export interface BaseField { 19 | id: string 20 | type: FieldType 21 | desc: string 22 | } 23 | 24 | export type SettingsField = HiddenField | InputField | ButtonField | ToggleField 25 | 26 | export interface ButtonField extends BaseField { 27 | type: FieldType.BUTTON 28 | text: string 29 | onClick?: () => void 30 | } 31 | export interface ToggleField extends BaseField { 32 | type: FieldType.TOGGLE 33 | onSelected?: (checked: boolean) => void 34 | } 35 | 36 | export interface InputField extends BaseField { 37 | type: FieldType.INPUT 38 | inputType: string 39 | onChange?: (value: string) => void 40 | } 41 | 42 | export interface HiddenField extends BaseField { 43 | type: FieldType.HIDDEN 44 | } 45 | 46 | if (!globalThis.__renderSettingSections) { 47 | globalThis.__settingSections = new Map() 48 | globalThis.__renderSettingSections = () => Array.from(globalThis.__settingSections.values()) 49 | } 50 | 51 | export class SettingsSection { 52 | public id: string 53 | 54 | constructor(public name: string, public sectionFields: { [key: string]: React.JSX.Element } = {}) { 55 | this.id = _.kebabCase(name) 56 | } 57 | 58 | pushSettings = () => { 59 | __settingSections.set(this.id, ) 60 | } 61 | 62 | toObject = () => 63 | new Proxy( 64 | {}, 65 | { 66 | get: (target, prop) => SettingsSection.getFieldValue(this.getId(prop.toString())), 67 | set: (target, prop, newValue) => { 68 | const id = this.getId(prop.toString()) 69 | if (SettingsSection.getFieldValue(id) === newValue) return false 70 | SettingsSection.setFieldValue(id, newValue) 71 | return true 72 | }, 73 | }, 74 | ) 75 | 76 | addButton = (props: FieldToProps) => { 77 | this.addField(FieldType.BUTTON, props, this.ButtonField) 78 | return this 79 | } 80 | 81 | addToggle = (props: FieldToProps, defaultValue: Task = () => false) => { 82 | this.addField(FieldType.TOGGLE, props, this.ToggleField, defaultValue) 83 | return this 84 | } 85 | 86 | addInput = (props: FieldToProps, defaultValue: Task = () => "") => { 87 | this.addField(FieldType.INPUT, props, this.InputField, defaultValue) 88 | return this 89 | } 90 | 91 | private addField( 92 | type: SF["type"], 93 | opts: FieldToProps, 94 | fieldComponent: (field: SF) => React.JSX.Element, 95 | defaultValue?: any, 96 | ) { 97 | if (defaultValue !== undefined) { 98 | const settingId = this.getId(opts.id) 99 | SettingsSection.setDefaultFieldValue(settingId, defaultValue) 100 | } 101 | const field = Object.assign({}, opts, { type }) as SF 102 | this.sectionFields[opts.id] = React.createElement(fieldComponent, field) 103 | } 104 | 105 | getId = (nameId: string) => ["extensions", this.id, nameId].join(":") 106 | 107 | private useStateFor = (id: string) => { 108 | const [value, setValueState] = React.useState(SettingsSection.getFieldValue(id)) 109 | 110 | return [ 111 | value, 112 | (newValue: A) => { 113 | if (newValue !== undefined) { 114 | setValueState(newValue) 115 | SettingsSection.setFieldValue(id, newValue) 116 | } 117 | }, 118 | ] as const 119 | } 120 | 121 | static getFieldValue = (id: string): R => JSON.parse(LocalStorage.get(id) ?? "null") 122 | 123 | static setFieldValue = (id: string, newValue: any) => LocalStorage.set(id, JSON.stringify(newValue)) 124 | 125 | private static setDefaultFieldValue = async (id: string, defaultValue: Task) => { 126 | if (SettingsSection.getFieldValue(id) === null) SettingsSection.setFieldValue(id, await defaultValue()) 127 | } 128 | 129 | private SettingsSection = () => ( 130 | <__SettingSection filterMatchQuery={this.name}> 131 | <__SectionTitle>{this.name} 132 | {Object.values(this.sectionFields)} 133 | 134 | ) 135 | 136 | SettingField = ({ field, children }: { field: SettingsField; children?: any }) => ( 137 | 138 |
139 | {field.desc} 140 |
141 |
{children}
142 |
143 | ) 144 | 145 | ButtonField = (field: ButtonField) => ( 146 | 147 | 148 | {field.text} 149 | 150 | 151 | ) 152 | 153 | ToggleField = (field: ToggleField) => { 154 | const id = this.getId(field.id) 155 | const [value, setValue] = this.useStateFor(id) 156 | return ( 157 | 158 | { 162 | setValue(checked) 163 | field.onSelected?.(checked) 164 | }} 165 | className="x-settings-button" 166 | /> 167 | 168 | ) 169 | } 170 | 171 | InputField = (field: InputField) => { 172 | const id = this.getId(field.id) 173 | const [value, setValue] = this.useStateFor(id) 174 | return ( 175 | 176 | { 183 | const value = e.currentTarget.value 184 | setValue(value) 185 | field.onChange?.(value) 186 | }} 187 | /> 188 | 189 | ) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /shared/util.ts: -------------------------------------------------------------------------------- 1 | export type SpotifyID = string 2 | export type SpotifyURI = string 3 | 4 | const { URI } = Spicetify 5 | const { PlayerAPI } = Spicetify.Platform 6 | 7 | export const SpotifyLoc = { 8 | before: { 9 | start: () => ({ before: "start" as const }), 10 | fromUri: (uri: SpotifyURI) => ({ before: { uri } }), 11 | fromUid: (uid: string) => ({ before: { uid } }), 12 | }, 13 | after: { 14 | end: () => ({ after: "end" as const }), 15 | fromUri: (uri: SpotifyURI) => ({ after: { uri } }), 16 | fromUid: (uid: string) => ({ after: { uid } }), 17 | }, 18 | } 19 | 20 | export const normalizeStr = (str: string) => 21 | str 22 | .normalize("NFKD") 23 | .replace(/\(.*\)/g, "") 24 | .replace(/\[.*\]/g, "") 25 | .replace(/-_,/g, " ") 26 | .replace(/[^a-zA-Z0-9 ]/g, "") 27 | .replace(/\s+/g, " ") 28 | .toLowerCase() 29 | .trim() 30 | 31 | export class PermanentMutationObserver extends MutationObserver { 32 | target: HTMLElement | null = null 33 | 34 | constructor( 35 | targetSelector: string, 36 | callback: MutationCallback, 37 | opts: MutationObserverInit = { 38 | childList: true, 39 | subtree: true, 40 | }, 41 | ) { 42 | super(callback) 43 | new MutationObserver(() => { 44 | const nextTarget = document.querySelector(targetSelector) 45 | if (nextTarget && !nextTarget.isEqualNode(this.target)) { 46 | this.target && this.disconnect() 47 | this.target = nextTarget 48 | this.observe(this.target, opts) 49 | } 50 | }).observe(document.body, { 51 | childList: true, 52 | subtree: true, 53 | }) 54 | } 55 | } 56 | 57 | export const waitForElement = ( 58 | selector: string, 59 | timeout = 5000, 60 | location = document.body, 61 | notEl?: E | null, 62 | ) => 63 | new Promise((resolve: (value: E) => void, reject) => { 64 | const onMutation = () => { 65 | const el = document.querySelector(selector) 66 | if (el) { 67 | if (notEl && el === notEl) { 68 | } else { 69 | observer.disconnect() 70 | return resolve(el) 71 | } 72 | } 73 | } 74 | 75 | const observer = new MutationObserver(onMutation) 76 | onMutation() 77 | 78 | observer.observe(location, { 79 | childList: true, 80 | subtree: true, 81 | }) 82 | 83 | if (timeout) 84 | setTimeout(() => { 85 | observer.disconnect() 86 | console.debug("waitForElement: timed out waiting for", selector) 87 | reject() 88 | }, timeout) 89 | }) 90 | 91 | export const formatUri = (uri: string) => URI.fromString(uri).toURI() 92 | 93 | export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) 94 | 95 | export const mainElement = document.querySelector("main")! 96 | export const [REACT_FIBER, REACT_PROPS] = Object.keys(mainElement) 97 | 98 | export const createQueueItem = 99 | (queued: boolean) => 100 | ({ uri, uid = "" }: { uri: string; uid?: string }) => ({ 101 | contextTrack: { 102 | uri, 103 | uid, 104 | metadata: { 105 | is_queued: queued.toString(), 106 | }, 107 | }, 108 | removed: [], 109 | blocked: [], 110 | provider: queued ? ("queue" as const) : ("context" as const), 111 | }) 112 | 113 | export const setQueue = async ( 114 | nextTracks: Array>>, 115 | contextUri?: string, 116 | ) => { 117 | const { _queue, _client } = PlayerAPI._queue 118 | const { prevTracks, queueRevision } = _queue 119 | 120 | const res = await _client.setQueue({ 121 | nextTracks, 122 | prevTracks, 123 | queueRevision, 124 | }) 125 | 126 | await PlayerAPI.skipToNext() 127 | 128 | if (contextUri) { 129 | await new Promise(resolve => { 130 | PlayerAPI.getEvents().addListener("queue_update", () => resolve(), { once: true }) 131 | }) 132 | await setPlayingContext(contextUri) 133 | } 134 | 135 | return res 136 | } 137 | 138 | export const setPlayingContext = (uri: string) => { 139 | const { sessionId } = PlayerAPI._state 140 | return PlayerAPI.updateContext(sessionId, { uri, url: "context://" + uri }) 141 | } 142 | -------------------------------------------------------------------------------- /snippets/compact-left-sidebar.scss: -------------------------------------------------------------------------------- 1 | .main-yourLibraryX-navItems { 2 | padding: 4px 0px; 3 | } 4 | 5 | .main-yourLibraryX-collapseButton > button[aria-label="Collapse Your Library"] { 6 | visibility: collapse; 7 | 8 | > span { 9 | visibility: visible; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /snippets/compact-queue-panel.scss: -------------------------------------------------------------------------------- 1 | .main-buddyFeed-content.queue-panel { 2 | padding: 10px; 3 | } 4 | 5 | .queue-panel .queue-queuePage-queuePage { 6 | margin-top: 10px !important; 7 | } 8 | 9 | .queue-queuePage-subHeader { 10 | margin-top: 8px; 11 | } 12 | 13 | .queue-panel .main-trackList-trackListRowGrid { 14 | padding: 0px 4px; 15 | } 16 | -------------------------------------------------------------------------------- /snippets/compact-tracklist.scss: -------------------------------------------------------------------------------- 1 | $color_1: black; 2 | $color_2: var(--spice-text); 3 | $background-color_1: rgba(255, 255, 255, 0.05); 4 | $background-color_2: rgba(255, 255, 255, 0.1); 5 | 6 | :root:root:root { 7 | .main-buddyFeed-content.queue-panel { 8 | padding: 10px; 9 | } 10 | .queue-queuePage-subHeader { 11 | margin-top: 8px; 12 | } 13 | .queue-panel .queue-queuePage-queuePage { 14 | margin-top: 10px !important; 15 | .main-trackList-trackListRowGrid { 16 | padding: 0px 4px; 17 | } 18 | .main-trackList-trackListRow > .main-trackList-rowSectionIndex { 19 | left: 4px !important; 20 | } 21 | } 22 | 23 | .main-trackList-trackListRowGrid { 24 | border-radius: 3px; 25 | border: none; 26 | transition: 200ms background-color; 27 | 28 | .main-type-mesto { 29 | transition: 300ms color; 30 | } 31 | .main-type-ballad { 32 | transition: 300ms color; 33 | } 34 | &:hover { 35 | background-color: $background-color_1 !important; 36 | } 37 | } 38 | .main-trackList-trackListRowGrid.main-trackList-selected { 39 | background-color: $background-color_2 !important; 40 | } 41 | .uCHqQ74vvHOnctGg0X0B, 42 | .queue-queuePage-queuePage, 43 | section:not(.album-albumPage-sectionWrapper):not(.search-searchResult-tracklistContainer) 44 | .contentSpacing:not(.artist-artistDiscography-tracklist) { 45 | .main-trackList-trackListHeader { 46 | display: none; 47 | } 48 | .main-trackList-trackListRow { 49 | > .main-trackList-rowSectionIndex { 50 | position: absolute; 51 | z-index: 1000; 52 | top: 8px; 53 | left: 16px; 54 | width: 40px; 55 | height: 40px; 56 | justify-content: center; 57 | text-indent: -1000px; 58 | } 59 | &:hover, 60 | &:focus-within { 61 | .main-trackList-rowSectionIndex { 62 | background: $background-color_1 !important; 63 | border-radius: 3px; 64 | } 65 | button.main-trackList-rowImagePlayButton { 66 | opacity: 1; 67 | color: $color_1; 68 | } 69 | } 70 | } 71 | .main-trackList-trackList[aria-colcount="3"] .main-trackList-trackListRow { 72 | grid-template-columns: [first] 4fr [last] minmax(70px, 1fr) !important; 73 | &:has(.starRatings) { 74 | grid-template-columns: [first] 4fr [var1] 1fr [last] minmax(70px, 1fr) !important; 75 | } 76 | } 77 | 78 | .main-trackList-trackList[aria-colcount="4"] .main-trackList-trackListRow { 79 | grid-template-columns: [first] 4fr [var1] 2fr [last] minmax(120px, 1fr) !important; 80 | &:has(.starRatings) { 81 | grid-template-columns: [first] 4fr [var1] 2fr [var2] 1fr [last] minmax(120px, 1fr) !important; 82 | } 83 | } 84 | 85 | .main-trackList-trackList[aria-colcount="5"] .main-trackList-trackListRow { 86 | grid-template-columns: [first] 6fr [var1] 4fr [var2] 3fr [last] minmax(120px, 1fr) !important; 87 | &:has(.starRatings) { 88 | grid-template-columns: [first] 6fr [var1] 4fr [var2] 3fr [var3] 2fr [last] minmax(120px, 1fr) !important; 89 | } 90 | } 91 | 92 | .main-trackList-trackList[aria-colcount="6"] .main-trackList-trackListRow { 93 | grid-template-columns: [first] 6fr [var1] 4fr [var2] 3fr [var3] minmax(120px, 2fr) [last] minmax(120px, 1fr) !important; 94 | &:has(.starRatings) { 95 | grid-template-columns: [first] 6fr [var1] 4fr [var2] 3fr [var3] minmax(120px, 2fr) [var3] 2fr [last] minmax( 96 | 120px, 97 | 1fr 98 | ) !important; 99 | } 100 | } 101 | 102 | .main-trackList-rowImageFallback { 103 | border-radius: 3px; 104 | } 105 | .main-trackList-active { 106 | .main-trackList-rowTitle { 107 | color: $color_2; 108 | text-shadow: 0px 0px 6px var(--spice-text); 109 | -webkit-text-stroke: thin; 110 | } 111 | .main-trackList-rowSectionIndex { 112 | background: $background-color_1 !important; 113 | border-radius: 3px; 114 | } 115 | button.main-trackList-rowImagePlayButton { 116 | opacity: 1; 117 | color: $color_1; 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /snippets/horizontal-nav-links.scss: -------------------------------------------------------------------------------- 1 | #spicetify-sticky-list { 2 | display: flex; 3 | flex-wrap: wrap; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | 8 | #spicetify-sticky-list span { 9 | display: none; 10 | } 11 | -------------------------------------------------------------------------------- /snippets/lobotomize-beautiful-lyrics.scss: -------------------------------------------------------------------------------- 1 | .lyrics-background-container { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /snippets/scrollable-spicetify-playlist-labels.scss: -------------------------------------------------------------------------------- 1 | .spicetify-playlist-labels-labels-container { 2 | height: unset !important; 3 | overflow-x: scroll !important; 4 | overflow-y: hidden !important; 5 | 6 | &::-webkit-scrollbar { 7 | display: none; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /snippets/thin-sidebar.scss: -------------------------------------------------------------------------------- 1 | .main-yourLibraryX-libraryRootlist:not(.main-yourLibraryX-libraryIsCollapsed) { 2 | .main-yourLibraryX-listItem .x-entityImage-imageContainer, 3 | .main-yourLibraryX-rowCover { 4 | width: 1.6em !important; 5 | height: 1.6em !important; 6 | } 7 | } 8 | 9 | .main-yourLibraryX-listItemGroup { 10 | grid-template-rows: none !important; 11 | } 12 | .main-yourLibraryX-listItemGroup * { 13 | padding-block: 0; 14 | } 15 | .main-yourLibraryX-listItem { 16 | [role="group"] { 17 | min-block-size: 0 !important; 18 | } 19 | .HeaderArea { 20 | .Column { 21 | flex-direction: row; 22 | gap: 0.5em; 23 | } 24 | * { 25 | padding-top: 0 !important; 26 | padding-bottom: 0 !important; 27 | } 28 | } 29 | } 30 | 31 | .main-yourLibraryX-listRowSubtitle { 32 | padding-top: 0px; 33 | } 34 | -------------------------------------------------------------------------------- /snippets/topbar-inside-titlebar.scss: -------------------------------------------------------------------------------- 1 | .spotify__container--is-desktop.spotify__os--is-windows { 2 | .main-topBar-container { 3 | padding-inline: 60px 150px !important; 4 | padding-bottom: 64px !important; 5 | } 6 | } 7 | .Root__top-container { 8 | grid-template-areas: "top-bar top-bar top-bar" "left-sidebar main-view right-sidebar" "now-playing-bar now-playing-bar now-playing-bar"; 9 | grid-template-rows: auto 1fr auto; 10 | .Root__top-bar { 11 | grid-area: top-bar; 12 | height: 32px; 13 | margin-top: -32px; 14 | .main-topBar-container { 15 | padding-inline: 80px 0px; 16 | padding-bottom: 64px; 17 | pointer-events: none; 18 | .main-topBar-topbarContentWrapper { 19 | > * { 20 | &:not(.main-topBar-searchBar) { 21 | justify-content: center; 22 | display: flex; 23 | } 24 | } 25 | } 26 | .main-topBar-topbarContent { 27 | > * { 28 | &:not(.main-topBar-searchBar) { 29 | justify-content: center; 30 | display: flex; 31 | } 32 | } 33 | app-region: drag !important; 34 | .main-entityHeader-topbarTitle { 35 | height: 32px; 36 | } 37 | } 38 | .main-topBar-background { 39 | display: none; 40 | } 41 | } 42 | } 43 | } 44 | .body-drag-top { 45 | height: 48px; 46 | } 47 | .main-view-container__scroll-node-child-spacer { 48 | height: 15px; 49 | } 50 | .playlist-playlist-playlist { 51 | margin-top: -15px !important; 52 | } 53 | .profile-userOverview-container { 54 | margin-top: -15px !important; 55 | } 56 | .artist-artistOverview-overview { 57 | margin-top: -15px !important; 58 | } 59 | .album-albumPage-sectionWrapper { 60 | margin-top: -15px !important; 61 | } 62 | .show-showPage-sectionWrapper { 63 | margin-top: -15px !important; 64 | } 65 | .A4dupilHPIEDfhXDE0m0 { 66 | margin-top: -15px !important; 67 | } 68 | .uCHqQ74vvHOnctGg0X0B { 69 | margin-top: -15px !important; 70 | } 71 | .lXcKpCtaEeFf1HifX139 { 72 | margin-top: -15px !important; 73 | } 74 | .MlK79hskRbFrN2OBjMkl { 75 | margin-top: -15px !important; 76 | } 77 | .dpN5ViPOceUWNB5EQPHN { 78 | margin-top: -15px !important; 79 | } 80 | .mmCZ5VczybT9VqKB5wFU { 81 | margin-top: -15px !important; 82 | } 83 | .queue-queuePage-queuePage { 84 | margin-top: 0 !important; 85 | top: 0 !important; 86 | } 87 | .search-searchCategory-SearchCategory { 88 | margin-top: 0 !important; 89 | top: 0 !important; 90 | } 91 | .artist-artistDiscography-topBar { 92 | margin-top: 0 !important; 93 | top: 0 !important; 94 | } 95 | -------------------------------------------------------------------------------- /tasks/bundle.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { appendFileSync } from "node:fs" 4 | import { basename } from "https://deno.land/std@0.201.0/path/basename.ts" 5 | import { join } from "https://deno.land/std@0.201.0/path/join.ts" 6 | import * as esbuild from "https://deno.land/x/esbuild@v0.19.4/mod.js" 7 | // import { denoResolverPlugin, denoLoaderPlugin } from "https://deno.land/x/esbuild_deno_loader@0.8.2/mod.ts" 8 | 9 | import autoprefixer from "https://deno.land/x/postcss_autoprefixer@0.2.8/mod.js" 10 | import { postCSSPlugin } from "./esbuild-plugin-postcss.ts" 11 | 12 | import { extractor } from "./front-matter.ts" 13 | import { sass } from "https://deno.land/x/denosass@1.0.6/src/mod.ts" 14 | 15 | const USER_REPO = "Delusoire/spicetify-extensions" 16 | 17 | const wrapInCssTag = (id: string, css: string) => `(async () => { 18 | if (!document.getElementById("${id}")) { 19 | const el = document.createElement("style") 20 | el.id = "${id}" 21 | el.textContent = ${JSON.stringify(css)} 22 | document.head.appendChild(el) 23 | } 24 | })()` 25 | 26 | const generatePrismContent = ( 27 | name: string, 28 | ) => `fetch("https://api.github.com/repos/${USER_REPO}/contents/dist/${name}/app.js") 29 | .then(res => res.json()) 30 | .then(json => atob(json.content)) 31 | .then(content => new Blob([content], { type: "application/javascript" })) 32 | .then(URL.createObjectURL) 33 | .then(url => import(url))` 34 | 35 | const readDirFullPath = (path: string) => Array.from(Deno.readDirSync(path)).map(file => join(path, file.name)) 36 | 37 | // Build 38 | 39 | const OUT = "dist" 40 | 41 | const encoder = new TextEncoder() 42 | const decoder = new TextDecoder() 43 | 44 | const extensions = readDirFullPath("extensions") 45 | const snippets = readDirFullPath("snippets") 46 | 47 | const extensionsData = extensions.map(async fullname => { 48 | const name = basename(fullname) 49 | const entry = join(fullname, "app.ts") 50 | 51 | await esbuild.build({ 52 | platform: "browser", 53 | target: ["esnext"], 54 | plugins: [ 55 | postCSSPlugin({ 56 | plugins: [autoprefixer()], 57 | modules: { 58 | generateScopedName: `[name]__[local]___[hash:base64:5]_${name}`, 59 | }, 60 | }), 61 | ], 62 | entryPoints: [entry], 63 | outdir: join(OUT, name), 64 | bundle: true, 65 | format: "esm", 66 | external: ["https://esm.sh/*"], 67 | // minify: true, 68 | sourcemap: "external", 69 | tsconfigRaw: `{ 70 | "compilerOptions": { 71 | "experimentalDecorators": true, 72 | "useDefineForClassFields": false, 73 | } 74 | }`, 75 | }) 76 | 77 | const s = join(OUT, name) 78 | const jsPath = join(s, "app.js") 79 | const cssPath = join(s, "app.css") 80 | const prismPath = join(s, "prism.mjs") 81 | 82 | try { 83 | const cssContent = decoder.decode(await Deno.readFile(cssPath)) 84 | appendFileSync(jsPath, wrapInCssTag(name + "-css", cssContent)) 85 | } catch (_) {} 86 | 87 | const prismContent = generatePrismContent(name) 88 | Deno.writeFile(prismPath, encoder.encode(prismContent)) 89 | 90 | const assets = join(fullname, "assets") 91 | const readme = join(assets, "README.md") 92 | const preview = join(assets, "preview.png") 93 | 94 | const readmeContent = decoder.decode(await Deno.readFile(readme)) 95 | const readmeFrontmatter = extractor(readmeContent).attributes 96 | 97 | return Object.assign(readmeFrontmatter, { 98 | name, 99 | preview, 100 | main: prismPath, 101 | readme, 102 | }) 103 | }) 104 | 105 | snippets.map(fullname => { 106 | const css = sass(fullname).to_string("compressed").toString() 107 | const snippetFile = join(OUT, fullname.replace(/\.scss$/, ".css")) 108 | Deno.writeFile(snippetFile, encoder.encode(css)) 109 | }) 110 | 111 | const manifest = await Promise.all(extensionsData) 112 | Deno.writeFile("manifest.json", encoder.encode(JSON.stringify(manifest))) 113 | 114 | esbuild.stop() 115 | -------------------------------------------------------------------------------- /tasks/debug.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { join } from "https://deno.land/std/path/join.ts" 4 | 5 | const PORT = 9222 6 | 7 | const command = new Deno.Command(join(Deno.env.get("APPDATA")!, "Spotify", "Spotify.exe"), { 8 | args: ["--remote-debugging-port=" + PORT], 9 | }) 10 | 11 | const process = command.spawn() 12 | console.log(await process.status) 13 | -------------------------------------------------------------------------------- /tasks/esbuild-plugin-postcss.ts: -------------------------------------------------------------------------------- 1 | import { ensureDir } from "https://deno.land/std/fs/ensure_dir.ts" 2 | import { dirname, join, relative, resolve } from "https://deno.land/std/path/mod.ts" 3 | import sass from "https://deno.land/x/denosass/mod.ts" 4 | import postcss, { Message, AcceptedPlugin as PostCSSPlugin } from "npm:postcss" 5 | import postcssModules from "npm:postcss-modules" 6 | import { _ } from "../shared/deps.ts" 7 | 8 | interface PostCSSPluginOptions { 9 | plugins: PostCSSPlugin[] 10 | modules: boolean | any 11 | rootDir: string 12 | writeToFile: boolean 13 | fileIsModule: (filename: string) => boolean 14 | } 15 | 16 | interface CSSModule { 17 | path: string 18 | map: { 19 | [key: string]: string 20 | } 21 | } 22 | 23 | export const defaultOptions: PostCSSPluginOptions = { 24 | plugins: [], 25 | modules: true, 26 | rootDir: Deno.cwd(), 27 | writeToFile: true, 28 | fileIsModule: filename => /\.module\.[^\.]+$/.test(filename), 29 | } 30 | 31 | const encoder = new TextEncoder() 32 | const decoder = new TextDecoder() 33 | 34 | export const postCSSPlugin = (opts?: Partial) => ({ 35 | name: "postcss", 36 | setup(build: any) { 37 | const { plugins, modules, rootDir, writeToFile, fileIsModule } = Object.assign(defaultOptions, opts ?? {}) 38 | 39 | const modulesMap = new Map() 40 | const modulesPlugin = postcssModules({ 41 | generateScopedName: "[name]__[local]___[hash:base64:5]", 42 | ...(modules === !!modules ? {} : modules), 43 | getJSON(filepath: any, json: any, outpath: any) { 44 | modulesMap.set(filepath, json) 45 | if (typeof modules.getJSON === "function") return modules.getJSON(filepath, json, outpath) 46 | }, 47 | }) 48 | 49 | build.onLoad({ filter: /.*/, namespace: "postcss-module" }, (args: any) => { 50 | const modmap = modulesMap.get(args?.pluginData?.originalPath) ?? {}, 51 | resolveDir = dirname(args.path), 52 | css = args?.pluginData?.css || "" 53 | 54 | return { 55 | resolveDir, 56 | contents: _.compact([ 57 | writeToFile ? `import ${JSON.stringify(args.path)};` : null, 58 | `export default ${JSON.stringify(modmap)};`, 59 | writeToFile ? null : `export const stylesheet=${JSON.stringify(css)};`, 60 | ]).join("\n"), 61 | } 62 | }) 63 | 64 | build.onLoad({ filter: /.*/, namespace: "postcss-text" }, (args: any) => { 65 | const css = args?.pluginData?.css || "" 66 | return { 67 | contents: `export default ${css};`, 68 | } 69 | }) 70 | 71 | build.onResolve({ filter: /\.scss$/ }, async (args: any) => { 72 | const sourceFullPath = resolve(args.resolveDir, args.path) 73 | 74 | const isModule = fileIsModule(sourceFullPath) 75 | 76 | const tmpDirPath = await Deno.makeTempDir() 77 | 78 | const sourceRelDir = relative(dirname(rootDir), dirname(sourceFullPath)) 79 | const tmpFilePath = resolve(tmpDirPath, sourceRelDir, sourceFullPath.replace(/\.scss$/, ".css")) 80 | 81 | await ensureDir(dirname(tmpFilePath)) 82 | const content = decoder.decode(await Deno.readFile(sourceFullPath)) 83 | const css_int = sass(content).to_string("compressed") 84 | 85 | const result = await postcss(isModule ? [modulesPlugin, ...plugins] : plugins).process(css_int, { 86 | from: sourceFullPath, 87 | to: tmpFilePath, 88 | }) 89 | 90 | if (writeToFile) { 91 | const data = encoder.encode(result.css) 92 | await Deno.writeFileSync(tmpFilePath, data) 93 | } 94 | 95 | return { 96 | namespace: isModule ? "postcss-module" : writeToFile ? "file" : "postcss-text", 97 | path: tmpFilePath, 98 | watchFiles: [result.opts.from].concat(getPostCssDependencies(result.messages)), 99 | pluginData: { 100 | originalPath: sourceFullPath, 101 | css: result.css, 102 | }, 103 | } 104 | }) 105 | }, 106 | }) 107 | 108 | const getFilesRecursive = (directory: string): string[] => 109 | Array.from(Deno.readDirSync(directory)).reduce((filepaths, file) => { 110 | const filepath = join(directory, file.name) 111 | const newFiles = file.isDirectory ? getFilesRecursive(filepath) : [filepath] 112 | return filepaths.concat(newFiles) 113 | }, [] as string[]) 114 | 115 | // let idCounter = 0 116 | 117 | /** 118 | * Generates an id that is guaranteed to be unique for the Node.JS instance. 119 | */ 120 | // function uniqueId(): string { 121 | // return Date.now().toString(16) + (idCounter++).toString(16) 122 | // } 123 | 124 | function getPostCssDependencies(messages: Message[]): string[] { 125 | const dependencies = [] 126 | for (const message of messages) { 127 | if (message.type == "dir-dependency") { 128 | dependencies.push(...getFilesRecursive(message.dir)) 129 | } else if (message.type == "dependency") { 130 | dependencies.push(message.file) 131 | } 132 | } 133 | return dependencies 134 | } 135 | -------------------------------------------------------------------------------- /tasks/front-matter.ts: -------------------------------------------------------------------------------- 1 | import parser from "npm:js-yaml" 2 | 3 | const optionalByteOrderMark = "\\ufeff?" 4 | const pattern = 5 | "^(" + 6 | optionalByteOrderMark + 7 | "(= yaml =|---)" + 8 | "$([\\s\\S]*?)" + 9 | "^(?:\\2|\\.\\.\\.)\\s*" + 10 | "$" + 11 | (Deno.build.os === "windows" ? "\\r?" : "") + 12 | "(?:\\n)?)" 13 | // NOTE: If this pattern uses the 'g' flag the `regex` constiable definition will 14 | // need to be moved down into the functions that use it. 15 | const regex = new RegExp(pattern, "m") 16 | 17 | export const extractor = (str: string) => { 18 | str = str || "" 19 | const lines = str.split(/(\r?\n)/) 20 | if (lines[0] && /= yaml =|---/.test(lines[0])) { 21 | return parse(str) 22 | } else { 23 | return { 24 | attributes: {}, 25 | body: str, 26 | bodyBegin: 1, 27 | } 28 | } 29 | } 30 | 31 | function computeLocation(match: RegExpExecArray, body: string) { 32 | let line = 1 33 | let pos = body.indexOf("\n") 34 | const offset = match.index + match[0].length 35 | 36 | while (pos !== -1) { 37 | if (pos >= offset) { 38 | return line 39 | } 40 | line++ 41 | pos = body.indexOf("\n", pos + 1) 42 | } 43 | 44 | return line 45 | } 46 | 47 | const parse = (str: string) => { 48 | const match = regex.exec(str) 49 | if (!match) { 50 | return { 51 | attributes: {}, 52 | body: str, 53 | bodyBegin: 1, 54 | } 55 | } 56 | 57 | const yaml = match[match.length - 1].replace(/^\s+|\s+$/g, "") 58 | const attributes = parser.load(yaml) || {} 59 | const body = str.replace(match[0], "") 60 | const line = computeLocation(match, str) 61 | 62 | return { 63 | attributes: attributes, 64 | body: body, 65 | bodyBegin: line, 66 | frontmatter: yaml, 67 | } 68 | } 69 | --------------------------------------------------------------------------------