├── .github ├── assets │ └── logo.png ├── dependabot.yml ├── labeler.yml └── workflows │ ├── build.yml │ ├── labeler.yml │ ├── linter.yml │ └── lintpr.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── CustomApps ├── lyrics-plus │ ├── OptionsMenu.js │ ├── Pages.js │ ├── PlaybarButton.js │ ├── ProviderGenius.js │ ├── ProviderLRCLIB.js │ ├── ProviderMusixmatch.js │ ├── ProviderNetease.js │ ├── Providers.js │ ├── README.md │ ├── Settings.js │ ├── TabBar.js │ ├── Translator.js │ ├── Utils.js │ ├── conversion.png │ ├── genius.png │ ├── index.js │ ├── kara.png │ ├── lockin.png │ ├── manifest.json │ ├── search.png │ └── style.css ├── new-releases │ ├── Card.js │ ├── Icons.js │ ├── Settings.js │ ├── index.js │ ├── manifest.json │ └── style.css └── reddit │ ├── Card.js │ ├── Icons.js │ ├── OptionsMenu.js │ ├── Settings.js │ ├── SortBox.js │ ├── TabBar.js │ ├── index.js │ ├── manifest.json │ └── style.css ├── Extensions ├── autoSkipExplicit.js ├── autoSkipVideo.js ├── bookmark.js ├── fullAppDisplay.js ├── keyboardShortcut.js ├── loopyLoop.js ├── popupLyrics.js ├── shuffle+.js ├── trashbin.js └── webnowplaying.js ├── LICENSE ├── README.md ├── Themes └── SpicetifyDefault │ ├── color.ini │ └── user.css ├── biome.json ├── css-map.json ├── globals.d.ts ├── go.mod ├── go.sum ├── install.ps1 ├── install.sh ├── jsHelper ├── expFeatures.js ├── homeConfig.js ├── sidebarConfig.js └── spicetifyWrapper.js ├── manifest.json ├── spicetify.go └── src ├── apply └── apply.go ├── backup └── backup.go ├── cmd ├── apply.go ├── auto.go ├── backup.go ├── block-updates.go ├── cmd.go ├── color.go ├── config-dir.go ├── config.go ├── devtools.go ├── patch.go ├── path.go ├── restart.go ├── update.go └── watch.go ├── preprocess └── preprocess.go ├── status ├── backup │ └── backup.go └── spotify │ └── spotify.go └── utils ├── color.go ├── config.go ├── file-utils.go ├── isAdmin ├── unix.go └── windows.go ├── path-utils.go ├── print.go ├── scanner.go ├── show-dir.go ├── ternary-bool.go ├── tracker.go ├── utils.go ├── vcs.go └── watcher.go /.github/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicetify/cli/0b70a046f97c6792a3a6342b03f1cccd370c597a/.github/assets/logo.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "gomod" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | 📦 aur: 2 | - "(aur)" 3 | 📦 snap: 4 | - "(snap)" 5 | 📦 brew: 6 | - "(brew)" 7 | 🪟 windows: 8 | - "(windows)" 9 | 🐧 linux: 10 | - "(linux)" 11 | 🍎 macos: 12 | - "(macos)" 13 | 🔵 extension: 14 | - "(extension|auto.*?skip|bookmark|full.*?app.*?display|keyboard.*?shortcut|shuffle|web.*?now.*?playing|popup.*?lyrics)" 15 | 🔴 custom app: 16 | - "(custom.*?app|lyrics.*?plus|new.*?releases|store|reddit|lyrics)" 17 | 🖇 duplicate: 18 | - "(duplicate)" 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "main" 7 | - "*/main/*/**" 8 | push: 9 | branches: 10 | - "main" 11 | - "*/main/*/**" 12 | release: 13 | types: [published] 14 | 15 | jobs: 16 | build: 17 | name: Build 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version-file: "go.mod" 28 | 29 | - name: Build 30 | run: go build . 31 | 32 | - name: Format 33 | run: | 34 | gofmt -s -l . 35 | if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi 36 | 37 | release: 38 | permissions: 39 | id-token: write 40 | contents: write 41 | attestations: write 42 | name: Release 43 | strategy: 44 | matrix: 45 | os: ["linux", "darwin", "windows"] 46 | arch: ["amd64", "arm64", "386"] 47 | runs-on: ubuntu-latest 48 | if: startsWith(github.ref, 'refs/tags/v2') 49 | needs: build 50 | 51 | steps: 52 | - name: Checkout 53 | uses: actions/checkout@v4 54 | 55 | - name: Get Tag 56 | run: echo "TAG=${GITHUB_REF#refs/*/v}" >> $GITHUB_ENV 57 | 58 | - name: Is Unix Platform 59 | run: echo "IS_UNIX=${{ matrix.os != 'windows' && matrix.arch != '386' }}" >> $GITHUB_ENV 60 | 61 | - name: Is Windows Platform 62 | run: echo "IS_WIN=${{ matrix.os == 'windows' }}" >> $GITHUB_ENV 63 | 64 | - name: Setup Go 65 | uses: actions/setup-go@v5 66 | with: 67 | go-version-file: "go.mod" 68 | 69 | - name: Build 70 | if: env.IS_UNIX == 'true' || env.IS_WIN == 'true' 71 | run: | 72 | go build -ldflags "-X main.version=${{ env.TAG }}" -o "./spicetify${{ matrix.os == 'windows' && '.exe' || '' }}" 73 | chmod +x "./spicetify${{ matrix.os == 'windows' && '.exe' || '' }}" 74 | env: 75 | GOOS: ${{ matrix.os }} 76 | GOARCH: ${{ matrix.arch }} 77 | CGO_ENABLED: 0 78 | 79 | - name: Attest output 80 | uses: actions/attest-build-provenance@v2 81 | if: env.IS_UNIX == 'true' || env.IS_WIN == 'true' 82 | with: 83 | subject-path: "./spicetify${{ matrix.os == 'windows' && '.exe' || '' }}" 84 | subject-name: "spicetify v${{ env.TAG }} (${{ matrix.os }}, ${{ (matrix.os == 'windows' && matrix.arch == 'amd64' && 'x64') || (matrix.os == 'windows' && matrix.arch == '386' && 'x32') || matrix.arch }})" 85 | 86 | - name: Upload Artifact for Signing 87 | if: env.IS_WIN == 'true' 88 | id: upload-artifact-for-signing 89 | uses: actions/upload-artifact@v4 90 | with: 91 | name: spicetify-${{ env.TAG }}-${{ matrix.os }}-${{ (matrix.arch == 'amd64' && 'x64') || (matrix.arch == 'arm64' && 'arm64') || 'x32' }}-unsigned 92 | path: ./spicetify.exe 93 | 94 | - name: Sign Windows Executable 95 | if: env.IS_WIN == 'true' 96 | uses: signpath/github-action-submit-signing-request@v1 97 | with: 98 | api-token: ${{ secrets.SIGNPATH_API_TOKEN }} 99 | organization-id: ${{ secrets.SIGNPATH_ORG_ID }} 100 | project-slug: "cli" 101 | signing-policy-slug: "release-signing" 102 | github-artifact-id: ${{ steps.upload-artifact-for-signing.outputs.artifact-id }} 103 | wait-for-completion: true 104 | output-artifact-directory: "./signed" 105 | 106 | - name: Copy Signed Windows Executable 107 | if: env.IS_WIN == 'true' 108 | run: | 109 | cp ./signed/spicetify.exe ./spicetify.exe 110 | 111 | - name: 7z - .tar 112 | if: env.IS_UNIX == 'true' 113 | uses: edgarrc/action-7z@v1 114 | with: 115 | args: 7z a -bb0 "spicetify-${{ env.TAG }}-${{ matrix.os }}-${{ matrix.arch }}.tar" "./spicetify" "./CustomApps" "./Extensions" "./Themes" "./jsHelper" "globals.d.ts" "css-map.json" 116 | 117 | - name: 7z - .tar.gz 118 | if: env.IS_UNIX == 'true' 119 | uses: edgarrc/action-7z@v1 120 | with: 121 | args: 7z a -bb0 -sdel -mx9 "spicetify-${{ env.TAG }}-${{ matrix.os }}-${{ matrix.arch }}.tar.gz" "spicetify-${{ env.TAG }}-${{ matrix.os }}-${{ matrix.arch }}.tar" 122 | 123 | - name: 7z - .zip 124 | if: env.IS_WIN == 'true' 125 | uses: edgarrc/action-7z@v1 126 | with: 127 | args: 7z a -bb0 -mx9 "spicetify-${{ env.TAG }}-${{ matrix.os }}-${{ (matrix.arch == 'amd64' && 'x64') || (matrix.arch == 'arm64' && 'arm64') || 'x32' }}.zip" "./spicetify.exe" "./CustomApps" "./Extensions" "./Themes" "./jsHelper" "globals.d.ts" "css-map.json" 128 | 129 | - name: Release 130 | if: env.IS_UNIX == 'true' || env.IS_WIN == 'true' 131 | uses: softprops/action-gh-release@v2 132 | with: 133 | files: "spicetify-${{ env.TAG }}-${{ matrix.os }}-${{ (matrix.os == 'windows' && matrix.arch == 'amd64' && 'x64') || (matrix.os == 'windows' && matrix.arch == '386' && 'x32') || matrix.arch }}.${{ matrix.os == 'windows' && 'zip' || 'tar.gz' }}" 134 | env: 135 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 136 | 137 | trigger-release: 138 | name: Trigger Homebrew/AUR Release 139 | runs-on: ubuntu-latest 140 | needs: release 141 | steps: 142 | - name: Update AUR package 143 | uses: fjogeleit/http-request-action@master 144 | with: 145 | url: https://vps.itsmeow.dev/spicetify-update 146 | method: GET 147 | - name: Update Winget package 148 | uses: vedantmgoyal9/winget-releaser@main 149 | with: 150 | identifier: Spicetify.Spicetify 151 | installers-regex: '-windows-\w+\.zip$' 152 | token: ${{ secrets.SPICETIFY_WINGET_TOKEN }} 153 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Issue Labeler 2 | 3 | on: 4 | issues: 5 | types: [opened, edited] 6 | 7 | jobs: 8 | triage: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: github/issue-labeler@v3.4 12 | with: 13 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 14 | configuration-path: .github/labeler.yml 15 | enable-versioned-regex: 0 16 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Code quality 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "main" 7 | - "*/main/*/**" 8 | push: 9 | branches: 10 | - "main" 11 | - "*/main/*/**" 12 | 13 | jobs: 14 | linter: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout the repo 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Biome 21 | uses: biomejs/setup-biome@v2 22 | with: 23 | version: latest 24 | 25 | - name: Run Biome 26 | run: biome ci . --files-ignore-unknown=true 27 | -------------------------------------------------------------------------------- /.github/workflows/lintpr.yml: -------------------------------------------------------------------------------- 1 | name: Lint Pull Request 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, edited, synchronize] 6 | 7 | jobs: 8 | lintpr: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Lint pull request title 12 | uses: amannn/action-semantic-pull-request@v5 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | with: 16 | disallowScopes: | 17 | [A-Z]+ 18 | subjectPattern: ^(?![A-Z]).+$ 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Executables 2 | bin 3 | cli 4 | spicetify 5 | spicetify-cli 6 | *.exe 7 | 8 | # MacOS 9 | .DS_Store 10 | 11 | # Node.js 12 | node_modules 13 | package-lock.json 14 | package.json 15 | 16 | # Logs 17 | install.log 18 | 19 | pnpm-lock.yaml 20 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["timonwong.shellcheck", "biomejs.biome", "golang.go", "ms-vscode.powershell"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[go]": { 4 | "editor.defaultFormatter": "golang.go" 5 | }, 6 | "[powershell]": { 7 | "editor.defaultFormatter": "ms-vscode.powershell" 8 | }, 9 | "[javascript][typescript][json]": { 10 | "editor.defaultFormatter": "biomejs.biome" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/PlaybarButton.js: -------------------------------------------------------------------------------- 1 | (function PlaybarButton() { 2 | if (!Spicetify.Platform.History) { 3 | setTimeout(PlaybarButton, 300); 4 | return; 5 | } 6 | 7 | const button = new Spicetify.Playbar.Button( 8 | "Lyrics Plus", 9 | ``, 10 | () => 11 | Spicetify.Platform.History.location.pathname !== "/lyrics-plus" 12 | ? Spicetify.Platform.History.push("/lyrics-plus") 13 | : Spicetify.Platform.History.goBack(), 14 | false, 15 | Spicetify.Platform.History.location.pathname === "/lyrics-plus", 16 | false 17 | ); 18 | 19 | const style = document.createElement("style"); 20 | style.innerHTML = ` 21 | .main-nowPlayingBar-lyricsButton { 22 | display: none !important; 23 | } 24 | li[data-id="/lyrics-plus"] { 25 | display: none; 26 | } 27 | `; 28 | style.classList.add("lyrics-plus:visual:playbar-button"); 29 | 30 | if (Spicetify.LocalStorage.get("lyrics-plus:visual:playbar-button") === "true") setPlaybarButton(); 31 | window.addEventListener("lyrics-plus", (event) => { 32 | if (event.detail?.name === "playbar-button") event.detail.value ? setPlaybarButton() : removePlaybarButton(); 33 | }); 34 | 35 | Spicetify.Platform.History.listen((location) => { 36 | button.active = location.pathname === "/lyrics-plus"; 37 | }); 38 | 39 | function setPlaybarButton() { 40 | document.head.appendChild(style); 41 | button.register(); 42 | } 43 | 44 | function removePlaybarButton() { 45 | style.remove(); 46 | button.deregister(); 47 | } 48 | })(); 49 | -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/ProviderGenius.js: -------------------------------------------------------------------------------- 1 | const ProviderGenius = (() => { 2 | function getChildDeep(parent, isDeep = false) { 3 | let acc = ""; 4 | 5 | if (!parent.children) { 6 | return acc; 7 | } 8 | 9 | for (const child of parent.children) { 10 | if (typeof child === "string") { 11 | acc += child; 12 | } else if (child.children) { 13 | acc += getChildDeep(child, true); 14 | } 15 | if (!isDeep) { 16 | acc += "\n"; 17 | } 18 | } 19 | return acc.trim(); 20 | } 21 | 22 | async function getNote(id) { 23 | const body = await Spicetify.CosmosAsync.get(`https://genius.com/api/annotations/${id}`); 24 | const response = body.response; 25 | let note = ""; 26 | 27 | // Authors annotations 28 | if (response.referent && response.referent.classification === "verified") { 29 | const referentsBody = await Spicetify.CosmosAsync.get(`https://genius.com/api/referents/${id}`); 30 | const referents = referentsBody.response; 31 | for (const ref of referents.referent.annotations) { 32 | note += getChildDeep(ref.body.dom); 33 | } 34 | } 35 | 36 | // Users annotations 37 | if (!note && response.annotation) { 38 | note = getChildDeep(response.annotation.body.dom); 39 | } 40 | 41 | // Users comments 42 | if (!note && response.annotation && response.annotation.top_comment) { 43 | note += getChildDeep(response.annotation.top_comment.body.dom); 44 | } 45 | note = note.replace(/\n\n\n?/, "\n"); 46 | 47 | return note; 48 | } 49 | 50 | function fetchHTML(url) { 51 | return new Promise((resolve, reject) => { 52 | const request = JSON.stringify({ 53 | method: "GET", 54 | uri: url, 55 | }); 56 | 57 | window.sendCosmosRequest({ 58 | request, 59 | persistent: false, 60 | onSuccess: resolve, 61 | onFailure: reject, 62 | }); 63 | }); 64 | } 65 | 66 | async function fetchLyricsVersion(results, index) { 67 | const result = results[index]; 68 | if (!result) { 69 | console.warn(result); 70 | return; 71 | } 72 | 73 | const site = await fetchHTML(result.url); 74 | const body = JSON.parse(site)?.body; 75 | if (!body) { 76 | return null; 77 | } 78 | 79 | let lyrics = ""; 80 | const parser = new DOMParser(); 81 | const htmlDoc = parser.parseFromString(body, "text/html"); 82 | const lyricsDiv = htmlDoc.querySelectorAll('div[data-lyrics-container="true"]'); 83 | 84 | for (const i of lyricsDiv) { 85 | lyrics += `${i.innerHTML}
`; 86 | } 87 | 88 | if (!lyrics?.length) { 89 | console.warn("forceError"); 90 | return null; 91 | } 92 | 93 | return lyrics; 94 | } 95 | 96 | async function fetchLyrics(info) { 97 | const titles = new Set([info.title]); 98 | 99 | const titleNoExtra = Utils.removeExtraInfo(info.title); 100 | titles.add(titleNoExtra); 101 | titles.add(Utils.removeSongFeat(info.title)); 102 | titles.add(Utils.removeSongFeat(titleNoExtra)); 103 | 104 | let lyrics; 105 | let hits; 106 | for (const title of titles) { 107 | const query = new URLSearchParams({ per_page: 20, q: `${info.artist} ${title}` }); 108 | const url = `https://genius.com/api/search/song?${query.toString()}`; 109 | 110 | const geniusSearch = await Spicetify.CosmosAsync.get(url); 111 | 112 | hits = geniusSearch.response.sections[0].hits.map((item) => ({ 113 | title: item.result.full_title, 114 | url: item.result.url, 115 | })); 116 | 117 | if (!hits.length) { 118 | continue; 119 | } 120 | 121 | lyrics = await fetchLyricsVersion(hits, 0); 122 | break; 123 | } 124 | 125 | if (!lyrics) { 126 | return { lyrics: null, versions: [] }; 127 | } 128 | 129 | return { lyrics, versions: hits }; 130 | } 131 | 132 | return { fetchLyrics, getNote, fetchLyricsVersion }; 133 | })(); 134 | -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/ProviderLRCLIB.js: -------------------------------------------------------------------------------- 1 | const ProviderLRCLIB = (() => { 2 | async function findLyrics(info) { 3 | const baseURL = "https://lrclib.net/api/get"; 4 | const durr = info.duration / 1000; 5 | const params = { 6 | track_name: info.title, 7 | artist_name: info.artist, 8 | album_name: info.album, 9 | duration: durr, 10 | }; 11 | 12 | const finalURL = `${baseURL}?${Object.keys(params) 13 | .map((key) => `${key}=${encodeURIComponent(params[key])}`) 14 | .join("&")}`; 15 | 16 | const body = await fetch(finalURL, { 17 | headers: { 18 | "x-user-agent": `spicetify v${Spicetify.Config.version} (https://github.com/spicetify/cli)`, 19 | }, 20 | }); 21 | 22 | if (body.status !== 200) { 23 | return { 24 | error: "Request error: Track wasn't found", 25 | uri: info.uri, 26 | }; 27 | } 28 | 29 | return await body.json(); 30 | } 31 | 32 | function getUnsynced(body) { 33 | const unsyncedLyrics = body?.plainLyrics; 34 | const isInstrumental = body.instrumental; 35 | if (isInstrumental) return [{ text: "♪ Instrumental ♪" }]; 36 | 37 | if (!unsyncedLyrics) return null; 38 | 39 | return Utils.parseLocalLyrics(unsyncedLyrics).unsynced; 40 | } 41 | 42 | function getSynced(body) { 43 | const syncedLyrics = body?.syncedLyrics; 44 | const isInstrumental = body.instrumental; 45 | if (isInstrumental) return [{ text: "♪ Instrumental ♪" }]; 46 | 47 | if (!syncedLyrics) return null; 48 | 49 | return Utils.parseLocalLyrics(syncedLyrics).synced; 50 | } 51 | 52 | return { findLyrics, getSynced, getUnsynced }; 53 | })(); 54 | -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/ProviderMusixmatch.js: -------------------------------------------------------------------------------- 1 | const ProviderMusixmatch = (() => { 2 | const headers = { 3 | authority: "apic-desktop.musixmatch.com", 4 | cookie: "x-mxm-token-guid=", 5 | }; 6 | 7 | async function findLyrics(info) { 8 | const baseURL = 9 | "https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get?format=json&namespace=lyrics_richsynched&subtitle_format=mxm&app_id=web-desktop-app-v1.0&"; 10 | 11 | const durr = info.duration / 1000; 12 | 13 | const params = { 14 | q_album: info.album, 15 | q_artist: info.artist, 16 | q_artists: info.artist, 17 | q_track: info.title, 18 | track_spotify_id: info.uri, 19 | q_duration: durr, 20 | f_subtitle_length: Math.floor(durr), 21 | usertoken: CONFIG.providers.musixmatch.token, 22 | }; 23 | 24 | const finalURL = 25 | baseURL + 26 | Object.keys(params) 27 | .map((key) => `${key}=${encodeURIComponent(params[key])}`) 28 | .join("&"); 29 | 30 | let body = await Spicetify.CosmosAsync.get(finalURL, null, headers); 31 | 32 | body = body.message.body.macro_calls; 33 | 34 | if (body["matcher.track.get"].message.header.status_code !== 200) { 35 | return { 36 | error: `Requested error: ${body["matcher.track.get"].message.header.mode}`, 37 | uri: info.uri, 38 | }; 39 | } 40 | if (body["track.lyrics.get"]?.message?.body?.lyrics?.restricted) { 41 | return { 42 | error: "Unfortunately we're not authorized to show these lyrics.", 43 | uri: info.uri, 44 | }; 45 | } 46 | 47 | return body; 48 | } 49 | 50 | async function getKaraoke(body) { 51 | const meta = body?.["matcher.track.get"]?.message?.body; 52 | if (!meta) { 53 | return null; 54 | } 55 | 56 | if (!meta.track.has_richsync || meta.track.instrumental) { 57 | return null; 58 | } 59 | 60 | const baseURL = "https://apic-desktop.musixmatch.com/ws/1.1/track.richsync.get?format=json&subtitle_format=mxm&app_id=web-desktop-app-v1.0&"; 61 | 62 | const params = { 63 | f_subtitle_length: meta.track.track_length, 64 | q_duration: meta.track.track_length, 65 | commontrack_id: meta.track.commontrack_id, 66 | usertoken: CONFIG.providers.musixmatch.token, 67 | }; 68 | 69 | const finalURL = 70 | baseURL + 71 | Object.keys(params) 72 | .map((key) => `${key}=${encodeURIComponent(params[key])}`) 73 | .join("&"); 74 | 75 | let result = await Spicetify.CosmosAsync.get(finalURL, null, headers); 76 | 77 | if (result.message.header.status_code !== 200) { 78 | return null; 79 | } 80 | 81 | result = result.message.body; 82 | 83 | const parsedKaraoke = JSON.parse(result.richsync.richsync_body).map((line) => { 84 | const startTime = line.ts * 1000; 85 | const endTime = line.te * 1000; 86 | const words = line.l; 87 | 88 | const text = words.map((word, index, words) => { 89 | const wordText = word.c; 90 | const wordStartTime = word.o * 1000; 91 | const nextWordStartTime = words[index + 1]?.o * 1000; 92 | 93 | const time = !Number.isNaN(nextWordStartTime) ? nextWordStartTime - wordStartTime : endTime - (wordStartTime + startTime); 94 | 95 | return { 96 | word: wordText, 97 | time, 98 | }; 99 | }); 100 | return { 101 | startTime, 102 | text, 103 | }; 104 | }); 105 | 106 | return parsedKaraoke; 107 | } 108 | 109 | function getSynced(body) { 110 | const meta = body?.["matcher.track.get"]?.message?.body; 111 | if (!meta) { 112 | return null; 113 | } 114 | 115 | const hasSynced = meta?.track?.has_subtitles; 116 | 117 | const isInstrumental = meta?.track?.instrumental; 118 | 119 | if (isInstrumental) { 120 | return [{ text: "♪ Instrumental ♪", startTime: "0000" }]; 121 | } 122 | if (hasSynced) { 123 | const subtitle = body["track.subtitles.get"]?.message?.body?.subtitle_list?.[0]?.subtitle; 124 | if (!subtitle) { 125 | return null; 126 | } 127 | 128 | return JSON.parse(subtitle.subtitle_body).map((line) => ({ 129 | text: line.text || "♪", 130 | startTime: line.time.total * 1000, 131 | })); 132 | } 133 | 134 | return null; 135 | } 136 | 137 | function getUnsynced(body) { 138 | const meta = body?.["matcher.track.get"]?.message?.body; 139 | if (!meta) { 140 | return null; 141 | } 142 | 143 | const hasUnSynced = meta.track.has_lyrics || meta.track.has_lyrics_crowd; 144 | 145 | const isInstrumental = meta?.track?.instrumental; 146 | 147 | if (isInstrumental) { 148 | return [{ text: "♪ Instrumental ♪" }]; 149 | } 150 | if (hasUnSynced) { 151 | const lyrics = body["track.lyrics.get"]?.message?.body?.lyrics?.lyrics_body; 152 | if (!lyrics) { 153 | return null; 154 | } 155 | return lyrics.split("\n").map((text) => ({ text })); 156 | } 157 | 158 | return null; 159 | } 160 | 161 | async function getTranslation(body) { 162 | const track_id = body?.["matcher.track.get"]?.message?.body?.track?.track_id; 163 | if (!track_id) return null; 164 | 165 | const selectedLanguage = CONFIG.visual["musixmatch-translation-language"] || "none"; 166 | if (selectedLanguage === "none") return null; 167 | 168 | const baseURL = 169 | "https://apic-desktop.musixmatch.com/ws/1.1/crowd.track.translations.get?translation_fields_set=minimal&comment_format=text&format=json&app_id=web-desktop-app-v1.0&"; 170 | 171 | const params = { 172 | track_id, 173 | selected_language: selectedLanguage, 174 | usertoken: CONFIG.providers.musixmatch.token, 175 | }; 176 | 177 | const finalURL = 178 | baseURL + 179 | Object.keys(params) 180 | .map((key) => `${key}=${encodeURIComponent(params[key])}`) 181 | .join("&"); 182 | 183 | let result = await Spicetify.CosmosAsync.get(finalURL, null, headers); 184 | 185 | if (result.message.header.status_code !== 200) return null; 186 | 187 | result = result.message.body; 188 | 189 | if (!result.translations_list?.length) return null; 190 | 191 | return result.translations_list.map(({ translation }) => ({ 192 | translation: translation.description, 193 | matchedLine: translation.matched_line, 194 | })); 195 | } 196 | 197 | return { findLyrics, getKaraoke, getSynced, getUnsynced, getTranslation }; 198 | })(); 199 | -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/ProviderNetease.js: -------------------------------------------------------------------------------- 1 | const ProviderNetease = (() => { 2 | const requestHeader = { 3 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0", 4 | }; 5 | 6 | async function findLyrics(info) { 7 | const searchURL = "https://music.xianqiao.wang/neteaseapiv2/search?limit=10&type=1&keywords="; 8 | const lyricURL = "https://music.xianqiao.wang/neteaseapiv2/lyric?id="; 9 | 10 | const cleanTitle = Utils.removeExtraInfo(Utils.removeSongFeat(Utils.normalize(info.title))); 11 | const finalURL = searchURL + encodeURIComponent(`${cleanTitle} ${info.artist}`); 12 | 13 | const searchResults = await Spicetify.CosmosAsync.get(finalURL, null, requestHeader); 14 | const items = searchResults.result.songs; 15 | if (!items?.length) { 16 | throw "Cannot find track"; 17 | } 18 | 19 | // normalized expected album name 20 | const neAlbumName = Utils.normalize(info.album); 21 | const expectedAlbumName = Utils.containsHanCharacter(neAlbumName) ? await Utils.toSimplifiedChinese(neAlbumName) : neAlbumName; 22 | let itemId = items.findIndex((val) => Utils.normalize(val.album.name) === expectedAlbumName); 23 | if (itemId === -1) itemId = items.findIndex((val) => Math.abs(info.duration - val.duration) < 3000); 24 | if (itemId === -1) itemId = items.findIndex((val) => val.name === cleanTitle); 25 | if (itemId === -1) throw "Cannot find track"; 26 | 27 | return await Spicetify.CosmosAsync.get(lyricURL + items[itemId].id, null, requestHeader); 28 | } 29 | 30 | const creditInfo = [ 31 | "\\s?作?\\s*词|\\s?作?\\s*曲|\\s?编\\s*曲?|\\s?监\\s*制?", 32 | ".*编写|.*和音|.*和声|.*合声|.*提琴|.*录|.*工程|.*工作室|.*设计|.*剪辑|.*制作|.*发行|.*出品|.*后期|.*混音|.*缩混", 33 | "原唱|翻唱|题字|文案|海报|古筝|二胡|钢琴|吉他|贝斯|笛子|鼓|弦乐", 34 | "lrc|publish|vocal|guitar|program|produce|write|mix", 35 | ]; 36 | const creditInfoRegExp = new RegExp(`^(${creditInfo.join("|")}).*(:|:)`, "i"); 37 | 38 | function containCredits(text) { 39 | return creditInfoRegExp.test(text); 40 | } 41 | 42 | function parseTimestamp(line) { 43 | // ["[ar:Beyond]"] 44 | // ["[03:10]"] 45 | // ["[03:10]", "lyrics"] 46 | // ["lyrics"] 47 | // ["[03:10]", "[03:10]", "lyrics"] 48 | // ["[1235,300]", "lyrics"] 49 | const matchResult = line.match(/(\[.*?\])|([^\[\]]+)/g); 50 | if (!matchResult?.length || matchResult.length === 1) { 51 | return { text: line }; 52 | } 53 | 54 | const textIndex = matchResult.findIndex((slice) => !slice.endsWith("]")); 55 | let text = ""; 56 | 57 | if (textIndex > -1) { 58 | text = matchResult.splice(textIndex, 1)[0]; 59 | text = Utils.capitalize(Utils.normalize(text, false)); 60 | } 61 | 62 | const time = matchResult[0].replace("[", "").replace("]", ""); 63 | 64 | return { time, text }; 65 | } 66 | 67 | function breakdownLine(text) { 68 | // (0,508)Don't(0,1) (0,151)want(0,1) (0,162)to(0,1) (0,100)be(0,1) (0,157)an(0,1) 69 | const components = text.split(/\(\d+,(\d+)\)/g); 70 | // ["", "508", "Don't", "1", " ", "151", "want" , "1" ...] 71 | const result = []; 72 | for (let i = 1; i < components.length; i += 2) { 73 | if (components[i + 1] === " ") continue; 74 | result.push({ 75 | word: `${components[i + 1]} `, 76 | time: Number.parseInt(components[i]), 77 | }); 78 | } 79 | return result; 80 | } 81 | 82 | function getKaraoke(list) { 83 | const lyricStr = list?.klyric?.lyric; 84 | 85 | if (!lyricStr) { 86 | return null; 87 | } 88 | 89 | const lines = lyricStr.split(/\r?\n/).map((line) => line.trim()); 90 | const karaoke = lines 91 | .map((line) => { 92 | const { time, text } = parseTimestamp(line); 93 | if (!time || !text) return null; 94 | 95 | const [key, value] = time.split(",") || []; 96 | const [start, durr] = [Number.parseFloat(key), Number.parseFloat(value)]; 97 | 98 | if (!Number.isNaN(start) && !Number.isNaN(durr) && !containCredits(text)) { 99 | return { 100 | startTime: start, 101 | // endTime: start + durr, 102 | text: breakdownLine(text), 103 | }; 104 | } 105 | return null; 106 | }) 107 | .filter(Boolean); 108 | 109 | if (!karaoke.length) { 110 | return null; 111 | } 112 | 113 | return karaoke; 114 | } 115 | 116 | function getSynced(list) { 117 | const lyricStr = list?.lrc?.lyric; 118 | let noLyrics = false; 119 | 120 | if (!lyricStr) { 121 | return null; 122 | } 123 | 124 | const lines = lyricStr.split(/\r?\n/).map((line) => line.trim()); 125 | const lyrics = lines 126 | .map((line) => { 127 | const { time, text } = parseTimestamp(line); 128 | if (text === "纯音乐, 请欣赏") noLyrics = true; 129 | if (!time || !text) return null; 130 | 131 | const [key, value] = time.split(":") || []; 132 | const [min, sec] = [Number.parseFloat(key), Number.parseFloat(value)]; 133 | if (!Number.isNaN(min) && !Number.isNaN(sec) && !containCredits(text)) { 134 | return { 135 | startTime: (min * 60 + sec) * 1000, 136 | text: text || "", 137 | }; 138 | } 139 | return null; 140 | }) 141 | .filter(Boolean); 142 | 143 | if (!lyrics.length || noLyrics) { 144 | return null; 145 | } 146 | return lyrics; 147 | } 148 | 149 | function getTranslation(list) { 150 | const lyricStr = list?.tlyric?.lyric; 151 | 152 | if (!lyricStr) { 153 | return null; 154 | } 155 | 156 | const lines = lyricStr.split(/\r?\n/).map((line) => line.trim()); 157 | const translation = lines 158 | .map((line) => { 159 | const { time, text } = parseTimestamp(line); 160 | if (!time || !text) return null; 161 | 162 | const [key, value] = time.split(":") || []; 163 | const [min, sec] = [Number.parseFloat(key), Number.parseFloat(value)]; 164 | if (!Number.isNaN(min) && !Number.isNaN(sec) && !containCredits(text)) { 165 | return { 166 | startTime: (min * 60 + sec) * 1000, 167 | text: text || "", 168 | }; 169 | } 170 | return null; 171 | }) 172 | .filter(Boolean); 173 | 174 | if (!translation.length) { 175 | return null; 176 | } 177 | return translation; 178 | } 179 | 180 | function getUnsynced(list) { 181 | const lyricStr = list?.lrc?.lyric; 182 | let noLyrics = false; 183 | 184 | if (!lyricStr) { 185 | return null; 186 | } 187 | 188 | const lines = lyricStr.split(/\r?\n/).map((line) => line.trim()); 189 | const lyrics = lines 190 | .map((line) => { 191 | const parsed = parseTimestamp(line); 192 | if (parsed.text === "纯音乐, 请欣赏") noLyrics = true; 193 | if (!parsed.text || containCredits(parsed.text)) return null; 194 | return parsed; 195 | }) 196 | .filter(Boolean); 197 | 198 | if (!lyrics.length || noLyrics) { 199 | return null; 200 | } 201 | return lyrics; 202 | } 203 | 204 | return { findLyrics, getKaraoke, getSynced, getUnsynced, getTranslation }; 205 | })(); 206 | -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/Providers.js: -------------------------------------------------------------------------------- 1 | const Providers = { 2 | spotify: async (info) => { 3 | const result = { 4 | uri: info.uri, 5 | karaoke: null, 6 | synced: null, 7 | unsynced: null, 8 | provider: "Spotify", 9 | copyright: null, 10 | }; 11 | 12 | const baseURL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"; 13 | const id = info.uri.split(":")[2]; 14 | let body; 15 | try { 16 | body = await Spicetify.CosmosAsync.get(`${baseURL + id}?format=json&vocalRemoval=false&market=from_token`); 17 | } catch { 18 | return { error: "Request error", uri: info.uri }; 19 | } 20 | 21 | const lyrics = body.lyrics; 22 | if (!lyrics) { 23 | return { error: "No lyrics", uri: info.uri }; 24 | } 25 | 26 | const lines = lyrics.lines; 27 | if (lyrics.syncType === "LINE_SYNCED") { 28 | result.synced = lines.map((line) => ({ 29 | startTime: line.startTimeMs, 30 | text: line.words, 31 | })); 32 | result.unsynced = result.synced; 33 | } else { 34 | result.unsynced = lines.map((line) => ({ 35 | text: line.words, 36 | })); 37 | } 38 | 39 | /** 40 | * to distinguish it from the existing Musixmatch, the provider will remain as Spotify. 41 | * if Spotify official lyrics support multiple providers besides Musixmatch in the future, please uncomment the under section. */ 42 | // result.provider = lyrics.provider; 43 | 44 | return result; 45 | }, 46 | musixmatch: async (info) => { 47 | const result = { 48 | error: null, 49 | uri: info.uri, 50 | karaoke: null, 51 | synced: null, 52 | unsynced: null, 53 | musixmatchTranslation: null, 54 | provider: "Musixmatch", 55 | copyright: null, 56 | }; 57 | 58 | let list; 59 | try { 60 | list = await ProviderMusixmatch.findLyrics(info); 61 | if (list.error) { 62 | throw ""; 63 | } 64 | } catch { 65 | result.error = "No lyrics"; 66 | return result; 67 | } 68 | 69 | const karaoke = await ProviderMusixmatch.getKaraoke(list); 70 | if (karaoke) { 71 | result.karaoke = karaoke; 72 | result.copyright = list["track.lyrics.get"].message?.body?.lyrics?.lyrics_copyright?.trim(); 73 | } 74 | const synced = ProviderMusixmatch.getSynced(list); 75 | if (synced) { 76 | result.synced = synced; 77 | result.copyright = list["track.subtitles.get"].message?.body?.subtitle_list?.[0]?.subtitle.lyrics_copyright.trim(); 78 | } 79 | const unsynced = synced || ProviderMusixmatch.getUnsynced(list); 80 | if (unsynced) { 81 | result.unsynced = unsynced; 82 | result.copyright = list["track.lyrics.get"].message?.body?.lyrics?.lyrics_copyright?.trim(); 83 | } 84 | const translation = await ProviderMusixmatch.getTranslation(list); 85 | if ((synced || unsynced) && translation) { 86 | const baseLyrics = synced ?? unsynced; 87 | result.musixmatchTranslation = baseLyrics.map((line) => ({ 88 | ...line, 89 | text: translation.find((t) => t.matchedLine === line.text)?.translation ?? line.text, 90 | originalText: line.text, 91 | })); 92 | } 93 | 94 | return result; 95 | }, 96 | netease: async (info) => { 97 | const result = { 98 | uri: info.uri, 99 | karaoke: null, 100 | synced: null, 101 | unsynced: null, 102 | neteaseTranslation: null, 103 | provider: "Netease", 104 | copyright: null, 105 | }; 106 | 107 | let list; 108 | try { 109 | list = await ProviderNetease.findLyrics(info); 110 | } catch { 111 | result.error = "No lyrics"; 112 | return result; 113 | } 114 | 115 | const karaoke = ProviderNetease.getKaraoke(list); 116 | if (karaoke) { 117 | result.karaoke = karaoke; 118 | } 119 | const synced = ProviderNetease.getSynced(list); 120 | if (synced) { 121 | result.synced = synced; 122 | } 123 | const unsynced = synced || ProviderNetease.getUnsynced(list); 124 | if (unsynced) { 125 | result.unsynced = unsynced; 126 | } 127 | const translation = ProviderNetease.getTranslation(list); 128 | if ((synced || unsynced) && Array.isArray(translation)) { 129 | const baseLyrics = synced ?? unsynced; 130 | result.neteaseTranslation = baseLyrics.map((line) => ({ 131 | ...line, 132 | text: translation.find((t) => t.startTime === line.startTime)?.text ?? line.text, 133 | originalText: line.text, 134 | })); 135 | } 136 | 137 | return result; 138 | }, 139 | lrclib: async (info) => { 140 | const result = { 141 | uri: info.uri, 142 | karaoke: null, 143 | synced: null, 144 | unsynced: null, 145 | provider: "lrclib", 146 | copyright: null, 147 | }; 148 | 149 | let list; 150 | try { 151 | list = await ProviderLRCLIB.findLyrics(info); 152 | } catch { 153 | result.error = "No lyrics"; 154 | return result; 155 | } 156 | 157 | const synced = ProviderLRCLIB.getSynced(list); 158 | if (synced) { 159 | result.synced = synced; 160 | } 161 | 162 | const unsynced = synced || ProviderLRCLIB.getUnsynced(list); 163 | 164 | if (unsynced) { 165 | result.unsynced = unsynced; 166 | } 167 | 168 | return result; 169 | }, 170 | genius: async (info) => { 171 | const { lyrics, versions } = await ProviderGenius.fetchLyrics(info); 172 | 173 | let versionIndex2 = 0; 174 | let genius2 = lyrics; 175 | if (CONFIG.visual["dual-genius"] && versions.length > 1) { 176 | genius2 = await ProviderGenius.fetchLyricsVersion(versions, 1); 177 | versionIndex2 = 1; 178 | } 179 | 180 | return { 181 | uri: info.uri, 182 | genius: lyrics, 183 | provider: "Genius", 184 | karaoke: null, 185 | synced: null, 186 | unsynced: null, 187 | copyright: null, 188 | error: null, 189 | versions, 190 | versionIndex: 0, 191 | genius2, 192 | versionIndex2, 193 | }; 194 | }, 195 | local: (info) => { 196 | let result = { 197 | uri: info.uri, 198 | karaoke: null, 199 | synced: null, 200 | unsynced: null, 201 | provider: "local", 202 | }; 203 | 204 | try { 205 | const savedLyrics = JSON.parse(localStorage.getItem("lyrics-plus:local-lyrics")); 206 | const lyrics = savedLyrics[info.uri]; 207 | if (!lyrics) { 208 | throw ""; 209 | } 210 | 211 | result = { 212 | ...result, 213 | ...lyrics, 214 | }; 215 | } catch { 216 | result.error = "No lyrics"; 217 | } 218 | 219 | return result; 220 | }, 221 | }; 222 | -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/README.md: -------------------------------------------------------------------------------- 1 | # Spicetify Custom App 2 | 3 | ### Lyrics Plus 4 | 5 | Show current track lyrics. Current lyrics providers: 6 | 7 | - Internal Spotify lyrics service. 8 | - Netease: From Chinese developers and users. Provides karaoke and synced lyrics. 9 | - Musixmatch: A company from Italy. Provided synced lyrics. 10 | - Genius: Provides unsynced lyrics but with description/insight from artists themselves (Disabled and cannot be used as a provider on `1.2.31` and higher). 11 | 12 | ![kara](./kara.png) 13 | 14 | ![genius](./genius.png) 15 | 16 | Different lyrics modes: Karaoke, Synced, Unsynced and Genius. At the moment, only Netease provides karaoke-able lyrics. Mode is automatically falled back, from Karaoke, Synced, Unsynced to Genius when lyrics are not available in that mode. 17 | 18 | Right click or Double click at any mode tab to "lock in", so lyric mode won't auto switch. It should show a dot next to mode name when mode is locked. Right click or double click again to unlock 19 | 20 | ![lockin](./lockin.png) 21 | 22 | Lyrics in Unsynced and Genius modes can be search and jump to. Hit Ctrl + Shift + F to open search box at bottom left of screen. Hit Enter/Shift+Enter to loop over results. 23 | 24 | ![search](./search.png) 25 | 26 | Choose between different option of displaying Japanese lyrics. (Furigana, Romaji, Hiragana, Katakana) 27 | 28 | ![conversion](./conversion.png) 29 | 30 | Customise colors, change providers' priorities in config menu. Config menu locates in Profile Menu (top right button with your user name). 31 | 32 | To install, run: 33 | 34 | ```bash 35 | spicetify config custom_apps lyrics-plus 36 | spicetify apply 37 | ``` 38 | 39 | ### Credits 40 | 41 | - A few parts of app code are taken from Spotify official app, including SyncedLyricsPage, CSS animation and TabBar. Please do not distribute these code else where out of Spotify/Spicetify context. 42 | - Netease synced lyrics parser is adapted from [mantou132/Spotify-Lyrics](https://github.com/mantou132/Spotify-Lyrics). Give it a Star if you like this app. 43 | - The algorithm for converting Japanese lyrics is based on [Hexenq's Kuroshiro](https://github.com/hexenq/kuroshiro). 44 | - The algorithm for converting Chinese lyrics is based on [BYVoid's OpenCC](https://github.com/BYVoid/OpenCC) via [nk2028's opencc-js](https://github.com/nk2028/opencc-js). 45 | - The algorithm for converting Korean lyrics is based on [fujaru's aromanize-js](https://github.com/fujaru/aromanize-js) 46 | - The algorithm for detecting Simplified Chinese is adapted from [nickdrewe's traditional-or-simplified](https://github.com/nickdrewe/traditional-or-simplified). 47 | -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/TabBar.js: -------------------------------------------------------------------------------- 1 | class TabBarItem extends react.Component { 2 | onSelect(event) { 3 | event.preventDefault(); 4 | this.props.switchTo(this.props.item.key); 5 | } 6 | onLock(event) { 7 | event.preventDefault(); 8 | this.props.lockIn(this.props.item.key); 9 | } 10 | render() { 11 | return react.createElement( 12 | "li", 13 | { 14 | className: "lyrics-tabBar-headerItem", 15 | onClick: this.onSelect.bind(this), 16 | onDoubleClick: this.onLock.bind(this), 17 | onContextMenu: this.onLock.bind(this), 18 | }, 19 | react.createElement( 20 | "a", 21 | { 22 | "aria-current": "page", 23 | className: `lyrics-tabBar-headerItemLink ${this.props.item.active ? "lyrics-tabBar-active" : ""}`, 24 | draggable: "false", 25 | href: "", 26 | }, 27 | react.createElement( 28 | "span", 29 | { 30 | className: "main-type-mestoBold", 31 | }, 32 | this.props.item.value 33 | ) 34 | ) 35 | ); 36 | } 37 | } 38 | 39 | const TabBarMore = react.memo(({ items, switchTo, lockIn }) => { 40 | const activeItem = items.find((item) => item.active); 41 | 42 | function onLock(event) { 43 | event.preventDefault(); 44 | if (activeItem) { 45 | lockIn(activeItem.key); 46 | } 47 | } 48 | return react.createElement( 49 | "li", 50 | { 51 | className: `lyrics-tabBar-headerItem ${activeItem ? "lyrics-tabBar-active" : ""}`, 52 | onDoubleClick: onLock, 53 | onContextMenu: onLock, 54 | }, 55 | react.createElement(OptionsMenu, { 56 | options: items, 57 | onSelect: switchTo, 58 | selected: activeItem, 59 | defaultValue: "More", 60 | bold: true, 61 | }) 62 | ); 63 | }); 64 | 65 | const TopBarContent = ({ links, activeLink, lockLink, switchCallback, lockCallback }) => { 66 | const resizeHost = document.querySelector( 67 | ".Root__main-view .os-resize-observer-host, .Root__main-view .os-size-observer, .Root__main-view .main-view-container__scroll-node" 68 | ); 69 | const [windowSize, setWindowSize] = useState(resizeHost.clientWidth); 70 | const resizeHandler = () => setWindowSize(resizeHost.clientWidth); 71 | 72 | useEffect(() => { 73 | const observer = new ResizeObserver(resizeHandler); 74 | observer.observe(resizeHost); 75 | return () => { 76 | observer.disconnect(); 77 | }; 78 | }, [resizeHandler]); 79 | 80 | return react.createElement( 81 | TabBarContext, 82 | null, 83 | react.createElement(TabBar, { 84 | className: "queue-queueHistoryTopBar-tabBar", 85 | links, 86 | activeLink, 87 | lockLink, 88 | switchCallback, 89 | lockCallback, 90 | windowSize, 91 | }) 92 | ); 93 | }; 94 | 95 | const TabBarContext = ({ children }) => { 96 | return reactDOM.createPortal( 97 | react.createElement( 98 | "div", 99 | { 100 | className: "main-topBar-topbarContent", 101 | }, 102 | children 103 | ), 104 | document.querySelector(".main-topBar-topbarContentWrapper") 105 | ); 106 | }; 107 | 108 | const TabBar = react.memo(({ links, activeLink, lockLink, switchCallback, lockCallback, windowSize = Number.POSITIVE_INFINITY }) => { 109 | const tabBarRef = react.useRef(null); 110 | const [childrenSizes, setChildrenSizes] = useState([]); 111 | const [availableSpace, setAvailableSpace] = useState(0); 112 | const [droplistItem, setDroplistItems] = useState([]); 113 | 114 | const options = []; 115 | for (let i = 0; i < links.length; i++) { 116 | const key = links[i]; 117 | if (spotifyVersion >= "1.2.31" && key === "genius") continue; 118 | let value = key[0].toUpperCase() + key.slice(1); 119 | if (key === lockLink) value = `• ${value}`; 120 | const active = key === activeLink; 121 | options.push({ key, value, active }); 122 | } 123 | 124 | useEffect(() => { 125 | if (!tabBarRef.current) return; 126 | setAvailableSpace(tabBarRef.current.clientWidth); 127 | }, [windowSize]); 128 | 129 | useEffect(() => { 130 | if (!tabBarRef.current) return; 131 | 132 | const tabbarItemSizes = []; 133 | for (const child of tabBarRef.current.children) { 134 | tabbarItemSizes.push(child.clientWidth); 135 | } 136 | 137 | setChildrenSizes(tabbarItemSizes); 138 | }, [links]); 139 | 140 | useEffect(() => { 141 | if (!tabBarRef.current) return; 142 | 143 | const totalSize = childrenSizes.reduce((a, b) => a + b, 0); 144 | 145 | // Can we render everything? 146 | if (totalSize <= availableSpace) { 147 | setDroplistItems([]); 148 | return; 149 | } 150 | 151 | // The `More` button can be set to _any_ of the children. So we 152 | // reserve space for the largest item instead of always taking 153 | // the last item. 154 | const viewMoreButtonSize = Math.max(...childrenSizes); 155 | 156 | // Figure out how many children we can render while also showing 157 | // the More button 158 | const itemsToHide = []; 159 | let stopWidth = viewMoreButtonSize; 160 | 161 | childrenSizes.forEach((childWidth, i) => { 162 | if (availableSpace >= stopWidth + childWidth) { 163 | stopWidth += childWidth; 164 | } else { 165 | // First elem is edit button 166 | itemsToHide.push(i); 167 | } 168 | }); 169 | 170 | setDroplistItems(itemsToHide); 171 | }, [availableSpace, childrenSizes]); 172 | 173 | return react.createElement( 174 | "nav", 175 | { 176 | className: "lyrics-tabBar lyrics-tabBar-nav", 177 | }, 178 | react.createElement( 179 | "ul", 180 | { 181 | className: "lyrics-tabBar-header", 182 | ref: tabBarRef, 183 | }, 184 | react.createElement("li", { 185 | className: "lyrics-tabBar-headerItem", 186 | }), 187 | options 188 | .filter((_, id) => !droplistItem.includes(id)) 189 | .map((item) => 190 | react.createElement(TabBarItem, { 191 | item, 192 | switchTo: switchCallback, 193 | lockIn: lockCallback, 194 | }) 195 | ), 196 | droplistItem.length || childrenSizes.length === 0 197 | ? react.createElement(TabBarMore, { 198 | items: droplistItem.map((i) => options[i]).filter(Boolean), 199 | switchTo: switchCallback, 200 | lockIn: lockCallback, 201 | }) 202 | : null 203 | ) 204 | ); 205 | }); 206 | -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/Translator.js: -------------------------------------------------------------------------------- 1 | const kuroshiroPath = "https://cdn.jsdelivr.net/npm/kuroshiro@1.2.0/dist/kuroshiro.min.js"; 2 | const kuromojiPath = "https://cdn.jsdelivr.net/npm/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js"; 3 | const aromanize = "https://cdn.jsdelivr.net/npm/aromanize@0.1.5/aromanize.min.js"; 4 | const openCCPath = "https://cdn.jsdelivr.net/npm/opencc-js@1.0.5/dist/umd/full.min.js"; 5 | 6 | const dictPath = "https:/cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict"; 7 | 8 | class Translator { 9 | constructor(lang, isUsingNetease = false) { 10 | this.finished = { 11 | ja: false, 12 | ko: false, 13 | zh: false, 14 | }; 15 | this.isUsingNetease = isUsingNetease; 16 | 17 | this.applyKuromojiFix(); 18 | this.injectExternals(lang); 19 | this.createTranslator(lang); 20 | } 21 | 22 | includeExternal(url) { 23 | if ((CONFIG.visual.translate || this.isUsingNetease) && !document.querySelector(`script[src="${url}"]`)) { 24 | const script = document.createElement("script"); 25 | script.setAttribute("type", "text/javascript"); 26 | script.setAttribute("src", url); 27 | document.head.appendChild(script); 28 | } 29 | } 30 | 31 | injectExternals(lang) { 32 | switch (lang?.slice(0, 2)) { 33 | case "ja": 34 | this.includeExternal(kuromojiPath); 35 | this.includeExternal(kuroshiroPath); 36 | break; 37 | case "ko": 38 | this.includeExternal(aromanize); 39 | break; 40 | case "zh": 41 | this.includeExternal(openCCPath); 42 | break; 43 | } 44 | } 45 | 46 | async awaitFinished(language) { 47 | return new Promise((resolve) => { 48 | const interval = setInterval(() => { 49 | this.injectExternals(language); 50 | this.createTranslator(language); 51 | 52 | const lan = language.slice(0, 2); 53 | if (this.finished[lan]) { 54 | clearInterval(interval); 55 | resolve(); 56 | } 57 | }, 100); 58 | }); 59 | } 60 | 61 | /** 62 | * Fix an issue with kuromoji when loading dict from external urls 63 | * Adapted from: https://github.com/mobilusoss/textlint-browser-runner/pull/7 64 | */ 65 | applyKuromojiFix() { 66 | if (typeof XMLHttpRequest.prototype.realOpen !== "undefined") return; 67 | XMLHttpRequest.prototype.realOpen = XMLHttpRequest.prototype.open; 68 | XMLHttpRequest.prototype.open = function (method, url, bool) { 69 | if (url.indexOf(dictPath.replace("https://", "https:/")) === 0) { 70 | this.realOpen(method, url.replace("https:/", "https://"), bool); 71 | } else { 72 | this.realOpen(method, url, bool); 73 | } 74 | }; 75 | } 76 | 77 | async createTranslator(lang) { 78 | switch (lang.slice(0, 2)) { 79 | case "ja": 80 | if (this.kuroshiro) return; 81 | if (typeof Kuroshiro === "undefined" || typeof KuromojiAnalyzer === "undefined") { 82 | await Translator.#sleep(50); 83 | return this.createTranslator(lang); 84 | } 85 | 86 | this.kuroshiro = new Kuroshiro.default(); 87 | this.kuroshiro.init(new KuromojiAnalyzer({ dictPath })).then( 88 | function () { 89 | this.finished.ja = true; 90 | }.bind(this) 91 | ); 92 | 93 | break; 94 | case "ko": 95 | if (this.Aromanize) return; 96 | if (typeof Aromanize === "undefined") { 97 | await Translator.#sleep(50); 98 | return this.createTranslator(lang); 99 | } 100 | 101 | this.Aromanize = Aromanize; 102 | this.finished.ko = true; 103 | break; 104 | case "zh": 105 | if (this.OpenCC) return; 106 | if (typeof OpenCC === "undefined") { 107 | await Translator.#sleep(50); 108 | return this.createTranslator(lang); 109 | } 110 | 111 | this.OpenCC = OpenCC; 112 | this.finished.zh = true; 113 | break; 114 | } 115 | } 116 | 117 | async romajifyText(text, target = "romaji", mode = "spaced") { 118 | if (!this.finished.ja) { 119 | await Translator.#sleep(100); 120 | return this.romajifyText(text, target, mode); 121 | } 122 | 123 | return this.kuroshiro.convert(text, { 124 | to: target, 125 | mode: mode, 126 | }); 127 | } 128 | 129 | async convertToRomaja(text, target) { 130 | if (!this.finished.ko) { 131 | await Translator.#sleep(100); 132 | return this.convertToRomaja(text, target); 133 | } 134 | 135 | if (target === "hangul") return text; 136 | return Aromanize.hangulToLatin(text, "rr-translit"); 137 | } 138 | 139 | async convertChinese(text, from, target) { 140 | if (!this.finished.zh) { 141 | await Translator.#sleep(100); 142 | return this.convertChinese(text, from, target); 143 | } 144 | 145 | const converter = this.OpenCC.Converter({ 146 | from: from, 147 | to: target, 148 | }); 149 | 150 | return converter(text); 151 | } 152 | 153 | /** 154 | * Async wrapper of `setTimeout`. 155 | * 156 | * @param {number} ms 157 | * @returns {Promise} 158 | */ 159 | static async #sleep(ms) { 160 | return new Promise((resolve) => setTimeout(resolve, ms)); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/conversion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicetify/cli/0b70a046f97c6792a3a6342b03f1cccd370c597a/CustomApps/lyrics-plus/conversion.png -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/genius.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicetify/cli/0b70a046f97c6792a3a6342b03f1cccd370c597a/CustomApps/lyrics-plus/genius.png -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/kara.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicetify/cli/0b70a046f97c6792a3a6342b03f1cccd370c597a/CustomApps/lyrics-plus/kara.png -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/lockin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicetify/cli/0b70a046f97c6792a3a6342b03f1cccd370c597a/CustomApps/lyrics-plus/lockin.png -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": { 3 | "ms": "Lyrics", 4 | "gu": "Lyrics", 5 | "ko": "Lyrics", 6 | "pa-IN": "Lyrics", 7 | "az": "Lyrics", 8 | "ru": "Текст", 9 | "uk": "Lyrics", 10 | "nb": "Lyrics", 11 | "sv": "Låttext", 12 | "sw": "Lyrics", 13 | "ur": "Lyrics", 14 | "bho": "Lyrics", 15 | "pa-PK": "Lyrics", 16 | "te": "Lyrics", 17 | "ro": "Lyrics", 18 | "vi": "Lời bài hát", 19 | "am": "Lyrics", 20 | "bn": "Lyrics", 21 | "en": "Lyrics", 22 | "id": "Lirik", 23 | "bg": "Lyrics", 24 | "da": "Lyrics", 25 | "es-419": "Letras", 26 | "mr": "Lyrics", 27 | "ml": "Lyrics", 28 | "th": "เนื้อเพลง", 29 | "tr": "Şarkı Sözleri", 30 | "is": "Lyrics", 31 | "fa": "Lyrics", 32 | "or": "Lyrics", 33 | "he": "Lyrics", 34 | "hi": "Lyrics", 35 | "zh-TW": "歌詞", 36 | "sr": "Lyrics", 37 | "pt-BR": "Letra", 38 | "zu": "Lyrics", 39 | "nl": "Songteksten", 40 | "es": "Letra", 41 | "lt": "Lyrics", 42 | "ja": "歌詞", 43 | "st": "Lyrics", 44 | "it": "Lyrics", 45 | "el": "Στίχοι", 46 | "pt-PT": "Lyrics", 47 | "kn": "Lyrics", 48 | "de": "Songtext", 49 | "fr": "Paroles", 50 | "ne": "Lyrics", 51 | "ar": "الكلمات", 52 | "af": "Lyrics", 53 | "et": "Lyrics", 54 | "pl": "Tekst", 55 | "ta": "Lyrics", 56 | "sl": "Lyrics", 57 | "pk": "Lyrics", 58 | "hr": "Lyrics", 59 | "sk": "Lyrics", 60 | "fi": "Sanat", 61 | "lv": "Lyrics", 62 | "fil": "Lyrics", 63 | "fr-CA": "Paroles", 64 | "cs": "Text", 65 | "zh-CN": "歌词", 66 | "hu": "Dalszöveg" 67 | }, 68 | "icon": "", 69 | "active-icon": "", 70 | "subfiles": [ 71 | "ProviderNetease.js", 72 | "ProviderMusixmatch.js", 73 | "ProviderGenius.js", 74 | "ProviderLRCLIB.js", 75 | "Providers.js", 76 | "Pages.js", 77 | "OptionsMenu.js", 78 | "TabBar.js", 79 | "Utils.js", 80 | "Settings.js", 81 | "Translator.js" 82 | ], 83 | "subfiles_extension": ["PlaybarButton.js"] 84 | } 85 | -------------------------------------------------------------------------------- /CustomApps/lyrics-plus/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicetify/cli/0b70a046f97c6792a3a6342b03f1cccd370c597a/CustomApps/lyrics-plus/search.png -------------------------------------------------------------------------------- /CustomApps/new-releases/Card.js: -------------------------------------------------------------------------------- 1 | function DraggableComponent({ uri, title, children }) { 2 | const dragHandler = Spicetify.ReactHook.DragHandler?.([uri], title); 3 | return dragHandler 4 | ? react.cloneElement(children, { 5 | onDragStart: dragHandler, 6 | draggable: "true", 7 | }) 8 | : children; 9 | } 10 | 11 | class Card extends react.Component { 12 | constructor(props) { 13 | super(props); 14 | Object.assign(this, props); 15 | this.href = URI.fromString(this.uri).toURLPath(true); 16 | this.artistHref = URI.fromString(this.artist.uri).toURLPath(true); 17 | const uriType = Spicetify.URI.fromString(this.uri)?.type; 18 | switch (uriType) { 19 | case Spicetify.URI.Type.ALBUM: 20 | case Spicetify.URI.Type.TRACK: 21 | this.menuType = Spicetify.ReactComponent.AlbumMenu; 22 | break; 23 | } 24 | this.menuType = this.menuType || "div"; 25 | } 26 | 27 | play(event) { 28 | Spicetify.Player.playUri(this.uri, this.context); 29 | event.stopPropagation(); 30 | } 31 | 32 | closeButtonClicked(event) { 33 | event.stopPropagation(); 34 | 35 | removeCards(this.props.uri); 36 | 37 | Spicetify.Snackbar.enqueueCustomSnackbar 38 | ? Spicetify.Snackbar.enqueueCustomSnackbar("dismissed-release", { 39 | keyPrefix: "dismissed-release", 40 | children: Spicetify.ReactComponent.Snackbar.wrapper({ 41 | children: Spicetify.ReactComponent.Snackbar.simpleLayout({ 42 | leading: Spicetify.ReactComponent.Snackbar.styledImage({ 43 | src: this.props.imageURL, 44 | imageHeight: "24px", 45 | imageWidth: "24px", 46 | }), 47 | center: Spicetify.React.createElement("div", { 48 | dangerouslySetInnerHTML: { 49 | __html: `Dismissed ${this.title}.`, 50 | }, 51 | }), 52 | trailing: Spicetify.ReactComponent.Snackbar.ctaText({ 53 | ctaText: "Undo", 54 | onCtaClick: () => removeCards(this.props.uri, "undo"), 55 | }), 56 | }), 57 | }), 58 | }) 59 | : Spicetify.showNotification(`Dismissed ${this.title} from
${this.artist.name}`); 60 | } 61 | 62 | render() { 63 | const detail = []; 64 | this.visual.type && detail.push(this.type); 65 | if (this.visual.count && this.trackCount) { 66 | detail.push(Spicetify.Locale.get("tracklist-header.songs-counter", this.trackCount)); 67 | } 68 | 69 | return react.createElement( 70 | Spicetify.ReactComponent.RightClickMenu || "div", 71 | { 72 | menu: react.createElement(this.menuType, { uri: this.uri }), 73 | }, 74 | react.createElement( 75 | "div", 76 | { 77 | className: "main-card-card", 78 | onClick: (event) => { 79 | History.push(this.href); 80 | event.preventDefault(); 81 | }, 82 | }, 83 | react.createElement( 84 | DraggableComponent, 85 | { 86 | uri: this.uri, 87 | title: this.title, 88 | }, 89 | react.createElement( 90 | "div", 91 | { 92 | className: "main-card-draggable", 93 | }, 94 | react.createElement( 95 | "div", 96 | { 97 | className: "main-card-imageContainer", 98 | }, 99 | react.createElement( 100 | "div", 101 | { 102 | className: "main-cardImage-imageWrapper", 103 | }, 104 | react.createElement( 105 | "div", 106 | {}, 107 | react.createElement("img", { 108 | "aria-hidden": "false", 109 | draggable: "false", 110 | loading: "lazy", 111 | src: this.imageURL, 112 | className: "main-image-image main-cardImage-image", 113 | }) 114 | ) 115 | ), 116 | react.createElement( 117 | "div", 118 | { 119 | className: "main-card-PlayButtonContainer", 120 | }, 121 | react.createElement( 122 | "div", 123 | { 124 | className: "main-playButton-PlayButton main-playButton-primary", 125 | "aria-label": Spicetify.Locale.get("play"), 126 | style: { "--size": "40px" }, 127 | onClick: this.play.bind(this), 128 | }, 129 | react.createElement( 130 | "button", 131 | null, 132 | react.createElement( 133 | "span", 134 | null, 135 | react.createElement( 136 | "svg", 137 | { 138 | height: "24", 139 | role: "img", 140 | width: "24", 141 | viewBox: "0 0 24 24", 142 | "aria-hidden": "true", 143 | }, 144 | react.createElement("polygon", { 145 | points: "21.57 12 5.98 3 5.98 21 21.57 12", 146 | fill: "currentColor", 147 | }) 148 | ) 149 | ) 150 | ) 151 | ) 152 | ), 153 | react.createElement( 154 | Spicetify.ReactComponent.TooltipWrapper, 155 | { label: "Dismiss" }, 156 | react.createElement( 157 | "button", 158 | { 159 | className: "main-card-closeButton", 160 | onClick: this.closeButtonClicked.bind(this), 161 | }, 162 | react.createElement( 163 | "svg", 164 | { 165 | width: "16", 166 | height: "16", 167 | viewBox: "0 0 16 16", 168 | xmlns: "http://www.w3.org/2000/svg", 169 | className: "main-card-closeButton-svg", 170 | }, 171 | react.createElement("path", { 172 | d: "M2.47 2.47a.75.75 0 0 1 1.06 0L8 6.94l4.47-4.47a.75.75 0 1 1 1.06 1.06L9.06 8l4.47 4.47a.75.75 0 1 1-1.06 1.06L8 9.06l-4.47 4.47a.75.75 0 0 1-1.06-1.06L6.94 8 2.47 3.53a.75.75 0 0 1 0-1.06Z", 173 | fill: "var(--spice-text)", 174 | fillRule: "evenodd", 175 | }) 176 | ) 177 | ) 178 | ) 179 | ), 180 | react.createElement( 181 | "div", 182 | { 183 | className: "main-card-cardMetadata", 184 | }, 185 | react.createElement( 186 | "a", 187 | { 188 | draggable: "false", 189 | title: this.title, 190 | className: "main-cardHeader-link", 191 | dir: "auto", 192 | href: this.href, 193 | }, 194 | react.createElement( 195 | "div", 196 | { 197 | className: "main-cardHeader-text main-type-balladBold", 198 | }, 199 | this.title 200 | ) 201 | ), 202 | detail.length > 0 && 203 | react.createElement( 204 | "div", 205 | { 206 | className: "main-cardSubHeader-root main-type-mestoBold new-releases-cardSubHeader", 207 | }, 208 | react.createElement("span", null, detail.join(" • ")) 209 | ), 210 | react.createElement( 211 | DraggableComponent, 212 | { 213 | uri: this.artist.uri, 214 | title: this.artist.name, 215 | }, 216 | react.createElement( 217 | "a", 218 | { 219 | className: "main-cardSubHeader-root main-type-mesto new-releases-cardSubHeader", 220 | href: this.artistHref, 221 | onClick: (event) => { 222 | History.push(this.artistHref); 223 | event.stopPropagation(); 224 | event.preventDefault(); 225 | }, 226 | }, 227 | react.createElement("span", null, this.artist.name) 228 | ) 229 | ) 230 | ), 231 | react.createElement("div", { 232 | className: "main-card-cardLink", 233 | }) 234 | ) 235 | ) 236 | ) 237 | ); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /CustomApps/new-releases/Icons.js: -------------------------------------------------------------------------------- 1 | const LoadingIcon = react.createElement( 2 | "svg", 3 | { 4 | width: "100px", 5 | height: "100px", 6 | viewBox: "0 0 100 100", 7 | preserveAspectRatio: "xMidYMid", 8 | }, 9 | react.createElement( 10 | "circle", 11 | { 12 | cx: "50", 13 | cy: "50", 14 | r: "0", 15 | fill: "none", 16 | stroke: "currentColor", 17 | "stroke-width": "2", 18 | }, 19 | react.createElement("animate", { 20 | attributeName: "r", 21 | repeatCount: "indefinite", 22 | dur: "1s", 23 | values: "0;40", 24 | keyTimes: "0;1", 25 | keySplines: "0 0.2 0.8 1", 26 | calcMode: "spline", 27 | begin: "0s", 28 | }), 29 | react.createElement("animate", { 30 | attributeName: "opacity", 31 | repeatCount: "indefinite", 32 | dur: "1s", 33 | values: "1;0", 34 | keyTimes: "0;1", 35 | keySplines: "0.2 0 0.8 1", 36 | calcMode: "spline", 37 | begin: "0s", 38 | }) 39 | ), 40 | react.createElement( 41 | "circle", 42 | { 43 | cx: "50", 44 | cy: "50", 45 | r: "0", 46 | fill: "none", 47 | stroke: "currentColor", 48 | "stroke-width": "2", 49 | }, 50 | react.createElement("animate", { 51 | attributeName: "r", 52 | repeatCount: "indefinite", 53 | dur: "1s", 54 | values: "0;40", 55 | keyTimes: "0;1", 56 | keySplines: "0 0.2 0.8 1", 57 | calcMode: "spline", 58 | begin: "-0.5s", 59 | }), 60 | react.createElement("animate", { 61 | attributeName: "opacity", 62 | repeatCount: "indefinite", 63 | dur: "1s", 64 | values: "1;0", 65 | keyTimes: "0;1", 66 | keySplines: "0.2 0 0.8 1", 67 | calcMode: "spline", 68 | begin: "-0.5s", 69 | }) 70 | ) 71 | ); 72 | 73 | class LoadMoreIcon extends react.Component { 74 | render() { 75 | return react.createElement( 76 | "div", 77 | { 78 | onClick: this.props.onClick, 79 | }, 80 | react.createElement( 81 | "p", 82 | { 83 | style: { 84 | fontSize: 100, 85 | lineHeight: "65px", 86 | }, 87 | }, 88 | "»" 89 | ), 90 | react.createElement( 91 | "span", 92 | { 93 | style: { 94 | fontSize: 20, 95 | }, 96 | }, 97 | "Load more" 98 | ) 99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /CustomApps/new-releases/Settings.js: -------------------------------------------------------------------------------- 1 | const ButtonSVG = ({ icon, active = true, onClick }) => { 2 | return react.createElement( 3 | "button", 4 | { 5 | className: `switch${active ? "" : " disabled"}`, 6 | onClick, 7 | }, 8 | react.createElement("svg", { 9 | width: 16, 10 | height: 16, 11 | viewBox: "0 0 16 16", 12 | fill: "currentColor", 13 | dangerouslySetInnerHTML: { 14 | __html: icon, 15 | }, 16 | }) 17 | ); 18 | }; 19 | 20 | const ButtonText = ({ text, active = true, onClick }) => { 21 | return react.createElement( 22 | "button", 23 | { 24 | className: `text${active ? "" : " disabled"}`, 25 | onClick, 26 | }, 27 | text 28 | ); 29 | }; 30 | 31 | const ConfigSlider = ({ name, defaultValue, onChange = () => {} }) => { 32 | const [active, setActive] = useState(defaultValue); 33 | 34 | const toggleState = useCallback(() => { 35 | const state = !active; 36 | setActive(state); 37 | onChange(state); 38 | }, [active]); 39 | 40 | return react.createElement( 41 | "div", 42 | { 43 | className: "setting-row", 44 | }, 45 | react.createElement( 46 | "label", 47 | { 48 | className: "col description", 49 | }, 50 | name 51 | ), 52 | react.createElement( 53 | "div", 54 | { 55 | className: "col action", 56 | }, 57 | react.createElement(ButtonSVG, { 58 | icon: Spicetify.SVGIcons.check, 59 | active, 60 | onClick: toggleState, 61 | }) 62 | ) 63 | ); 64 | }; 65 | 66 | const ConfigSelection = ({ name, defaultValue, options, onChange = () => {} }) => { 67 | const [value, setValue] = useState(defaultValue); 68 | 69 | const setValueCallback = useCallback( 70 | (event) => { 71 | const value = event.target.value; 72 | setValue(value); 73 | onChange(value); 74 | }, 75 | [value] 76 | ); 77 | 78 | return react.createElement( 79 | "div", 80 | { 81 | className: "setting-row", 82 | }, 83 | react.createElement( 84 | "label", 85 | { 86 | className: "col description", 87 | }, 88 | name 89 | ), 90 | react.createElement( 91 | "div", 92 | { 93 | className: "col action", 94 | }, 95 | react.createElement( 96 | "select", 97 | { 98 | value, 99 | onChange: setValueCallback, 100 | }, 101 | Object.keys(options).map((item) => 102 | react.createElement( 103 | "option", 104 | { 105 | value: item, 106 | }, 107 | options[item] 108 | ) 109 | ) 110 | ) 111 | ) 112 | ); 113 | }; 114 | 115 | const ConfigInput = ({ name, defaultValue, onChange = () => {} }) => { 116 | const [value, setValue] = useState(defaultValue); 117 | 118 | const setValueCallback = useCallback( 119 | (event) => { 120 | const value = event.target.value; 121 | setValue(value); 122 | onChange(value); 123 | }, 124 | [value] 125 | ); 126 | 127 | return react.createElement( 128 | "div", 129 | { 130 | className: "setting-row", 131 | }, 132 | react.createElement( 133 | "label", 134 | { 135 | className: "col description", 136 | }, 137 | name 138 | ), 139 | react.createElement( 140 | "div", 141 | { 142 | className: "col action", 143 | }, 144 | react.createElement("input", { 145 | value, 146 | onChange: setValueCallback, 147 | }) 148 | ) 149 | ); 150 | }; 151 | 152 | const OptionList = ({ items, onChange }) => { 153 | const [_, setItems] = useState(items); 154 | return items.map((item) => { 155 | if (!item.when()) { 156 | return; 157 | } 158 | return react.createElement(item.type, { 159 | name: item.desc, 160 | defaultValue: item.defaultValue, 161 | options: item.options, 162 | onChange: (value) => { 163 | onChange(item.key, value); 164 | setItems([...items]); 165 | }, 166 | }); 167 | }); 168 | }; 169 | 170 | function openConfig() { 171 | const configContainer = react.createElement( 172 | "div", 173 | { 174 | id: `${APP_NAME}-config-container`, 175 | }, 176 | react.createElement(OptionList, { 177 | items: [ 178 | { 179 | desc: "Time range", 180 | key: "range", 181 | defaultValue: CONFIG.range, 182 | type: ConfigSelection, 183 | options: { 184 | 30: "30 days", 185 | 60: "60 days", 186 | 90: "90 days", 187 | 120: "120 days", 188 | }, 189 | when: () => true, 190 | }, 191 | { 192 | desc: "Date locale", 193 | key: "locale", 194 | defaultValue: CONFIG.locale, 195 | type: ConfigInput, 196 | when: () => true, 197 | }, 198 | { 199 | desc: "Relative date", 200 | key: "relative", 201 | defaultValue: CONFIG.relative, 202 | type: ConfigSlider, 203 | when: () => true, 204 | }, 205 | { 206 | desc: "Show type", 207 | key: "visual:type", 208 | defaultValue: CONFIG.visual.type, 209 | type: ConfigSlider, 210 | when: () => true, 211 | }, 212 | { 213 | desc: "Show track count", 214 | key: "visual:count", 215 | defaultValue: CONFIG.visual.count, 216 | type: ConfigSlider, 217 | when: () => true, 218 | }, 219 | { 220 | desc: "Fetch new podcast", 221 | key: "podcast", 222 | defaultValue: CONFIG.podcast, 223 | type: ConfigSlider, 224 | when: () => true, 225 | }, 226 | { 227 | desc: "Fetch new music", 228 | key: "music", 229 | defaultValue: CONFIG.music, 230 | type: ConfigSlider, 231 | when: () => true, 232 | }, 233 | { 234 | desc: Spicetify.Locale.get("artist.albums"), 235 | key: "album", 236 | defaultValue: CONFIG.album, 237 | type: ConfigSlider, 238 | when: () => CONFIG.music, 239 | }, 240 | { 241 | desc: Spicetify.Locale.get("artist.singles"), 242 | key: "single-ep", 243 | defaultValue: CONFIG["single-ep"], 244 | type: ConfigSlider, 245 | when: () => CONFIG.music, 246 | }, 247 | /* { 248 | desc: Spicetify.Locale.get("artist.appears-on"), 249 | key: "appears-on", 250 | defaultValue: CONFIG["appears-on"], 251 | type: ConfigSlider, 252 | when: () => CONFIG["music"] 253 | }, */ 254 | { 255 | desc: Spicetify.Locale.get("artist.compilations"), 256 | key: "compilations", 257 | defaultValue: CONFIG.compilations, 258 | type: ConfigSlider, 259 | when: () => CONFIG.music, 260 | }, 261 | ], 262 | onChange: (name, value) => { 263 | const subs = name.split(":"); 264 | if (subs.length > 1) { 265 | CONFIG[subs[0]][subs[1]] = value; 266 | gridUpdatePostsVisual(); 267 | } else { 268 | CONFIG[name] = value; 269 | } 270 | localStorage.setItem(`${APP_NAME}:${name}`, value); 271 | }, 272 | }), 273 | react.createElement( 274 | "div", 275 | { 276 | className: "setting-row", 277 | }, 278 | react.createElement( 279 | "label", 280 | { 281 | className: "col description", 282 | }, 283 | "Dismissed releases" 284 | ), 285 | react.createElement( 286 | "div", 287 | { 288 | className: "col action", 289 | }, 290 | react.createElement(ButtonText, { 291 | text: Spicetify.Locale.get("equalizer.reset"), 292 | onClick: removeCards.bind(this, null, "reset"), 293 | }) 294 | ) 295 | ) 296 | ); 297 | 298 | Spicetify.PopupModal.display({ 299 | title: Spicetify.Locale.get("new_releases"), 300 | content: configContainer, 301 | }); 302 | } 303 | -------------------------------------------------------------------------------- /CustomApps/new-releases/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": { 3 | "ms": "Keluaran Baharu", 4 | "gu": "નવા રિલીઝ", 5 | "ko": "최신 음악", 6 | "pa-IN": "ਨਵੇਂ ਰਿਲੀਜ਼", 7 | "az": "Yeni buraxılışlar", 8 | "ru": "Новые релизы", 9 | "uk": "Нові релізи", 10 | "nb": "Nye utgivelser", 11 | "sv": "Nya releaser", 12 | "sw": "Matoleo Mapya", 13 | "ur": "نئی ریلیزز", 14 | "bho": "नवका रिलीज़", 15 | "pa-PK": "نویں ریلیزاں", 16 | "te": "క్రొత్త రిలీజ్‌లు", 17 | "ro": "Lansări noi", 18 | "vi": "Mới Phát Hành", 19 | "am": "አዳዲስ የተለቀቁ", 20 | "bn": "নতুন রিলিজ", 21 | "en": "New Releases", 22 | "id": "Rilis Terbaru", 23 | "bg": "Нови издания", 24 | "da": "Nye udgivelser", 25 | "es-419": "Nuevos Lanzamientos", 26 | "mr": "नवीन रिलीझ", 27 | "ml": "പുതിയ റിലീസുകള്‍", 28 | "th": "ออกใหม่ล่าสุด", 29 | "tr": "Yeni Çıkanlar", 30 | "is": "Nýjar útgáfur", 31 | "fa": "تولیدات جدید", 32 | "or": "ନୂଆ ରିଲିଜଗୁଡ଼ିକ", 33 | "he": "מה חדש?", 34 | "hi": "नई रिलीज़", 35 | "zh-TW": "最新發行", 36 | "sr": "Nova izdanja", 37 | "pt-BR": "Novos lançamentos", 38 | "zu": "Ezisanda Kukhishwa", 39 | "nl": "Nieuwe releases", 40 | "es": "Novedades", 41 | "lt": "Nauji leidimai", 42 | "ja": "ニューリリース", 43 | "st": "Nova izdanja", 44 | "it": "Nuove uscite", 45 | "el": "Νέες κυκλοφορίες", 46 | "pt-PT": "Novos lançamentos", 47 | "kn": "ಹೊಸ ಬಿಡುಗಡೆಗಳು", 48 | "de": "Neuerscheinungen", 49 | "fr": "Nouveautés", 50 | "ne": "नयाँ रिलिजहरू", 51 | "ar": "الإصدارات الجديدة", 52 | "af": "Nuwe vrystellings", 53 | "et": "Uus muusika", 54 | "pl": "Nowe wydania", 55 | "ta": "புதிய வெளியீடுகள்", 56 | "sl": "Nove izdaje", 57 | "pk": "New Releases", 58 | "hr": "Nova izdanja", 59 | "sk": "Novinky", 60 | "fi": "Uudet julkaisut", 61 | "lv": "Jaunumi", 62 | "fil": "Mga Bagong Release", 63 | "fr-CA": "Nouveautés", 64 | "cs": "Čerstvé novinky", 65 | "zh-CN": "新歌热播", 66 | "hu": "Újdonságok" 67 | }, 68 | "icon": "", 69 | "active-icon": "", 70 | "subfiles": ["Card.js", "Icons.js", "Settings.js"] 71 | } 72 | -------------------------------------------------------------------------------- /CustomApps/new-releases/style.css: -------------------------------------------------------------------------------- 1 | .setting-row::after { 2 | content: ""; 3 | display: table; 4 | clear: both; 5 | } 6 | .setting-row .col { 7 | display: flex; 8 | padding: 10px 0; 9 | align-items: center; 10 | } 11 | .setting-row .col.description { 12 | float: left; 13 | padding-right: 15px; 14 | cursor: default; 15 | } 16 | .setting-row .col.action { 17 | float: right; 18 | text-align: right; 19 | } 20 | button.switch { 21 | align-items: center; 22 | border: 0px; 23 | border-radius: 50%; 24 | background-color: rgba(var(--spice-rgb-shadow), 0.7); 25 | color: var(--spice-text); 26 | cursor: pointer; 27 | display: flex; 28 | margin-inline-start: 12px; 29 | padding: 8px; 30 | } 31 | button.switch.disabled, 32 | button.switch[disabled] { 33 | color: rgba(var(--spice-rgb-text), 0.3); 34 | } 35 | button.switch.small { 36 | width: 22px; 37 | height: 22px; 38 | padding: 6px; 39 | } 40 | button.text { 41 | font-size: 12px; 42 | line-height: 16px; 43 | font-weight: 700; 44 | letter-spacing: 0.1em; 45 | text-transform: uppercase; 46 | text-align: center; 47 | color: var(--spice-text); 48 | background-color: initial; 49 | padding: 7px 15px; 50 | border: 1px solid var(--spice-text); 51 | -webkit-box-sizing: border-box; 52 | box-sizing: border-box; 53 | border-radius: 4px; 54 | margin-inline-start: 12px; 55 | } 56 | #new-releases-config-container input { 57 | width: 100%; 58 | margin-top: 10px; 59 | padding: 0 5px; 60 | height: 32px; 61 | border: 0; 62 | color: var(--spice-text); 63 | background-color: initial; 64 | border-bottom: 1px solid var(--spice-text); 65 | } 66 | 67 | option { 68 | background-color: var(--spice-button); 69 | } 70 | 71 | .new-releases-header { 72 | -webkit-box-pack: justify; 73 | -webkit-box-align: center; 74 | align-content: space-between; 75 | align-items: center; 76 | color: var(--spice-text); 77 | display: flex; 78 | justify-content: space-between; 79 | margin: 16px 0; 80 | text-transform: capitalize; 81 | } 82 | 83 | .new-releases-controls-container { 84 | position: relative; 85 | align-items: center; 86 | display: flex; 87 | } 88 | 89 | .new-releases-cardSubHeader { 90 | color: var(--spice-subtext); 91 | } 92 | .new-releases-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton button { 93 | -webkit-tap-highlight-color: transparent; 94 | background-color: transparent; 95 | border: 0px; 96 | border-radius: 500px; 97 | display: inline-block; 98 | position: relative; 99 | touch-action: manipulation; 100 | transition-duration: 33ms; 101 | transition-property: background-color, border-color, color, box-shadow, filter, transform; 102 | user-select: none; 103 | vertical-align: middle; 104 | transform: translate3d(0px, 0px, 0px); 105 | padding: 0px; 106 | min-inline-size: 0px; 107 | align-self: center; 108 | } 109 | .new-releases-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton span { 110 | -webkit-tap-highlight-color: transparent; 111 | position: relative; 112 | background-color: var(--spice-button-active); 113 | color: var(--spice-sidebar); 114 | display: flex; 115 | border-radius: 500px; 116 | font-size: inherit; 117 | min-block-size: 48px; 118 | -webkit-box-align: center; 119 | align-items: center; 120 | -webkit-box-pack: center; 121 | justify-content: center; 122 | inline-size: 48px; 123 | block-size: 48px; 124 | } 125 | .new-releases-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton span:hover { 126 | transform: scale(1.04); 127 | } 128 | 129 | .main-card-closeButton { 130 | pointer-events: all; 131 | position: absolute !important; 132 | top: 8px; 133 | right: 8px; 134 | -webkit-box-align: center; 135 | -ms-flex-align: center; 136 | -webkit-box-pack: center; 137 | -ms-flex-pack: center; 138 | align-items: center; 139 | background-color: rgba(var(--spice-rgb-shadow), 0.7); 140 | border: 0; 141 | border-radius: 500px; 142 | color: var(--spice-sidebar); 143 | display: -webkit-box; 144 | display: -ms-flexbox; 145 | display: flex; 146 | height: 28px; 147 | justify-content: center; 148 | -webkit-transform: scale(1); 149 | transform: scale(1); 150 | -webkit-transform-origin: center; 151 | transform-origin: center; 152 | width: 28px; 153 | visibility: hidden; 154 | opacity: 0; 155 | transition: visibility 0s, opacity 0.3s ease; 156 | } 157 | 158 | .main-card-closeButton:active { 159 | transform: scale(1) !important; 160 | } 161 | .main-card-closeButton:hover { 162 | transform: scale(1.1); 163 | } 164 | 165 | .main-card-card:hover .main-card-closeButton { 166 | visibility: visible; 167 | opacity: 1; 168 | transition: visibility 0s, opacity 0.3s ease; 169 | } 170 | 171 | .new-releases-header + .main-gridContainer-gridContainer { 172 | grid-template-columns: repeat(var(--column-count), minmax(var(--min-container-width), 1fr)) !important; 173 | } 174 | -------------------------------------------------------------------------------- /CustomApps/reddit/Card.js: -------------------------------------------------------------------------------- 1 | class Card extends react.Component { 2 | constructor(props) { 3 | super(props); 4 | Object.assign(this, props); 5 | const uriObj = URI.fromString(this.uri); 6 | this.href = uriObj.toURLPath(true); 7 | 8 | this.uriType = uriObj.type; 9 | switch (this.uriType) { 10 | case URI.Type.ALBUM: 11 | case URI.Type.TRACK: 12 | this.menuType = Spicetify.ReactComponent.AlbumMenu; 13 | break; 14 | case URI.Type.ARTIST: 15 | this.menuType = Spicetify.ReactComponent.ArtistMenu; 16 | break; 17 | case URI.Type.PLAYLIST: 18 | case URI.Type.PLAYLIST_V2: 19 | this.menuType = Spicetify.ReactComponent.PlaylistMenu; 20 | break; 21 | case URI.Type.SHOW: 22 | this.menuType = Spicetify.ReactComponent.PodcastShowMenu; 23 | break; 24 | } 25 | this.menuType = this.menuType || "div"; 26 | } 27 | 28 | play(event) { 29 | Spicetify.Player.playUri(this.uri, this.context); 30 | event.stopPropagation(); 31 | } 32 | 33 | getSubtitle() { 34 | let subtitle; 35 | if ((this.uriType === URI.Type.ALBUM || this.uriType === URI.Type.TRACK) && Array.isArray(this.subtitle)) { 36 | subtitle = this.subtitle.map((artist) => { 37 | const artistHref = URI.fromString(artist.uri).toURLPath(true); 38 | return react.createElement( 39 | "a", 40 | { 41 | href: artistHref, 42 | onClick: (event) => { 43 | event.preventDefault(); 44 | event.stopPropagation(); 45 | History.push(artistHref); 46 | }, 47 | }, 48 | react.createElement("span", null, artist.name) 49 | ); 50 | }); 51 | // Insert commas between elements 52 | subtitle = subtitle.flatMap((el, i, arr) => (arr.length - 1 !== i ? [el, ", "] : el)); 53 | } else { 54 | subtitle = react.createElement( 55 | "div", 56 | { 57 | className: `${this.visual.longDescription ? "reddit-longDescription " : ""}main-cardSubHeader-root main-type-mesto reddit-cardSubHeader`, 58 | as: "div", 59 | }, 60 | react.createElement("span", null, this.subtitle) 61 | ); 62 | } 63 | return react.createElement( 64 | "div", 65 | { 66 | className: "reddit-cardSubHeader main-type-mesto", 67 | }, 68 | subtitle 69 | ); 70 | } 71 | 72 | getFollowers() { 73 | if (this.visual.followers && (this.uriType === URI.Type.PLAYLIST || this.uriType === URI.Type.PLAYLIST_V2)) { 74 | return react.createElement( 75 | "div", 76 | { 77 | className: "main-cardSubHeader-root main-type-mestoBold reddit-cardSubHeader", 78 | as: "div", 79 | }, 80 | react.createElement("span", null, Spicetify.Locale.get("user.followers", this.followersCount)) 81 | ); 82 | } 83 | } 84 | 85 | render() { 86 | const detail = []; 87 | this.visual.type && detail.push(this.type); 88 | this.visual.upvotes && detail.push(`▲ ${this.upvotes}`); 89 | 90 | return react.createElement( 91 | Spicetify.ReactComponent.RightClickMenu || "div", 92 | { 93 | menu: react.createElement(this.menuType, { uri: this.uri }), 94 | }, 95 | react.createElement( 96 | "div", 97 | { 98 | className: "main-card-card", 99 | onClick: (event) => { 100 | History.push(this.href); 101 | event.preventDefault(); 102 | }, 103 | }, 104 | react.createElement( 105 | "div", 106 | { 107 | className: "main-card-draggable", 108 | draggable: "true", 109 | }, 110 | react.createElement( 111 | "div", 112 | { 113 | className: "main-card-imageContainer", 114 | }, 115 | react.createElement( 116 | "div", 117 | { 118 | className: "main-cardImage-imageWrapper", 119 | }, 120 | react.createElement( 121 | "div", 122 | {}, 123 | react.createElement("img", { 124 | "aria-hidden": "false", 125 | draggable: "false", 126 | loading: "lazy", 127 | src: this.imageURL, 128 | className: "main-image-image main-cardImage-image", 129 | }) 130 | ) 131 | ), 132 | react.createElement( 133 | "div", 134 | { 135 | className: "main-card-PlayButtonContainer", 136 | }, 137 | react.createElement( 138 | "div", 139 | { 140 | className: "main-playButton-PlayButton main-playButton-primary", 141 | "aria-label": Spicetify.Locale.get("play"), 142 | style: { "--size": "40px" }, 143 | onClick: this.play.bind(this), 144 | }, 145 | react.createElement( 146 | "button", 147 | null, 148 | react.createElement( 149 | "span", 150 | null, 151 | react.createElement( 152 | "svg", 153 | { 154 | height: "24", 155 | role: "img", 156 | width: "24", 157 | viewBox: "0 0 24 24", 158 | "aria-hidden": "true", 159 | }, 160 | react.createElement("polygon", { 161 | points: "21.57 12 5.98 3 5.98 21 21.57 12", 162 | fill: "currentColor", 163 | }) 164 | ) 165 | ) 166 | ) 167 | ) 168 | ) 169 | ), 170 | react.createElement( 171 | "div", 172 | { 173 | className: "main-card-cardMetadata", 174 | }, 175 | react.createElement( 176 | "a", 177 | { 178 | draggable: "false", 179 | title: this.title, 180 | className: "main-cardHeader-link", 181 | dir: "auto", 182 | href: this.href, 183 | }, 184 | react.createElement( 185 | "div", 186 | { 187 | className: "main-cardHeader-text main-type-balladBold", 188 | as: "div", 189 | }, 190 | this.title 191 | ) 192 | ), 193 | detail.length > 0 && 194 | react.createElement( 195 | "div", 196 | { 197 | className: "main-cardSubHeader-root main-type-mestoBold reddit-cardSubHeader", 198 | as: "div", 199 | }, 200 | react.createElement("span", null, detail.join(" ‒ ")) 201 | ), 202 | this.getFollowers(), 203 | this.getSubtitle() 204 | ) 205 | ) 206 | ) 207 | ); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /CustomApps/reddit/Icons.js: -------------------------------------------------------------------------------- 1 | class LoadingIcon extends react.Component { 2 | render() { 3 | return react.createElement( 4 | "svg", 5 | { 6 | width: "100px", 7 | height: "100px", 8 | viewBox: "0 0 100 100", 9 | preserveAspectRatio: "xMidYMid", 10 | }, 11 | react.createElement( 12 | "circle", 13 | { 14 | cx: "50", 15 | cy: "50", 16 | r: "0", 17 | fill: "none", 18 | stroke: "currentColor", 19 | "stroke-width": "2", 20 | }, 21 | react.createElement("animate", { 22 | attributeName: "r", 23 | repeatCount: "indefinite", 24 | dur: "1s", 25 | values: "0;40", 26 | keyTimes: "0;1", 27 | keySplines: "0 0.2 0.8 1", 28 | calcMode: "spline", 29 | begin: "0s", 30 | }), 31 | react.createElement("animate", { 32 | attributeName: "opacity", 33 | repeatCount: "indefinite", 34 | dur: "1s", 35 | values: "1;0", 36 | keyTimes: "0;1", 37 | keySplines: "0.2 0 0.8 1", 38 | calcMode: "spline", 39 | begin: "0s", 40 | }) 41 | ), 42 | react.createElement( 43 | "circle", 44 | { 45 | cx: "50", 46 | cy: "50", 47 | r: "0", 48 | fill: "none", 49 | stroke: "currentColor", 50 | "stroke-width": "2", 51 | }, 52 | react.createElement("animate", { 53 | attributeName: "r", 54 | repeatCount: "indefinite", 55 | dur: "1s", 56 | values: "0;40", 57 | keyTimes: "0;1", 58 | keySplines: "0 0.2 0.8 1", 59 | calcMode: "spline", 60 | begin: "-0.5s", 61 | }), 62 | react.createElement("animate", { 63 | attributeName: "opacity", 64 | repeatCount: "indefinite", 65 | dur: "1s", 66 | values: "1;0", 67 | keyTimes: "0;1", 68 | keySplines: "0.2 0 0.8 1", 69 | calcMode: "spline", 70 | begin: "-0.5s", 71 | }) 72 | ) 73 | ); 74 | } 75 | } 76 | 77 | class LoadMoreIcon extends react.Component { 78 | render() { 79 | return react.createElement( 80 | "div", 81 | { 82 | onClick: this.props.onClick, 83 | }, 84 | react.createElement( 85 | "p", 86 | { 87 | style: { 88 | fontSize: 100, 89 | lineHeight: "65px", 90 | }, 91 | }, 92 | "»" 93 | ), 94 | react.createElement( 95 | "span", 96 | { 97 | style: { 98 | fontSize: 20, 99 | }, 100 | }, 101 | "Load more" 102 | ) 103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /CustomApps/reddit/OptionsMenu.js: -------------------------------------------------------------------------------- 1 | const OptionsMenuItemIcon = react.createElement( 2 | "svg", 3 | { 4 | width: 16, 5 | height: 16, 6 | viewBox: "0 0 16 16", 7 | fill: "currentColor", 8 | }, 9 | react.createElement("path", { 10 | d: "M13.985 2.383L5.127 12.754 1.388 8.375l-.658.77 4.397 5.149 9.618-11.262z", 11 | }) 12 | ); 13 | 14 | const OptionsMenuItem = react.memo(({ onSelect, value, isSelected }) => { 15 | return react.createElement( 16 | Spicetify.ReactComponent.MenuItem, 17 | { 18 | onClick: onSelect, 19 | icon: isSelected ? OptionsMenuItemIcon : null, 20 | trailingIcon: isSelected ? OptionsMenuItemIcon : null, 21 | }, 22 | value 23 | ); 24 | }); 25 | 26 | const OptionsMenu = react.memo(({ options, onSelect, selected, defaultValue, bold = false }) => { 27 | /** 28 | * ) } 30 | * > 31 | * 35 | * 36 | */ 37 | const menuRef = react.useRef(null); 38 | return react.createElement( 39 | Spicetify.ReactComponent.ContextMenu, 40 | { 41 | menu: react.createElement( 42 | Spicetify.ReactComponent.Menu, 43 | {}, 44 | options.map(({ key, value }) => 45 | react.createElement(OptionsMenuItem, { 46 | value, 47 | onSelect: () => { 48 | onSelect(key); 49 | // Close menu on item click 50 | menuRef.current?.click(); 51 | }, 52 | isSelected: selected?.key === key, 53 | }) 54 | ) 55 | ), 56 | trigger: "click", 57 | action: "toggle", 58 | renderInline: false, 59 | }, 60 | react.createElement( 61 | "button", 62 | { 63 | className: "optionsMenu-dropBox", 64 | ref: menuRef, 65 | }, 66 | react.createElement( 67 | "span", 68 | { 69 | className: bold ? "main-type-mestoBold" : "main-type-mesto", 70 | }, 71 | selected?.value || defaultValue 72 | ), 73 | react.createElement( 74 | "svg", 75 | { 76 | height: "16", 77 | width: "16", 78 | fill: "currentColor", 79 | viewBox: "0 0 16 16", 80 | }, 81 | react.createElement("path", { 82 | d: "M3 6l5 5.794L13 6z", 83 | }) 84 | ) 85 | ) 86 | ); 87 | }); 88 | -------------------------------------------------------------------------------- /CustomApps/reddit/Settings.js: -------------------------------------------------------------------------------- 1 | let configContainer; 2 | 3 | function openConfig() { 4 | if (configContainer) { 5 | Spicetify.PopupModal.display({ 6 | title: "Reddit", 7 | content: configContainer, 8 | }); 9 | return; 10 | } 11 | 12 | CONFIG.servicesElement = {}; 13 | 14 | configContainer = document.createElement("div"); 15 | configContainer.id = "reddit-config-container"; 16 | 17 | const optionHeader = document.createElement("h2"); 18 | optionHeader.innerText = "Options"; 19 | 20 | const serviceHeader = document.createElement("h2"); 21 | serviceHeader.innerText = "Subreddits"; 22 | 23 | const serviceContainer = document.createElement("div"); 24 | 25 | function stackServiceElements() { 26 | CONFIG.services.forEach((name, index) => { 27 | const el = CONFIG.servicesElement[name]; 28 | 29 | const [up, down] = el.querySelectorAll("button"); 30 | if (CONFIG.services.length === 1) { 31 | up.disabled = true; 32 | down.disabled = true; 33 | } else if (index === 0) { 34 | up.disabled = true; 35 | down.disabled = false; 36 | } else if (index === CONFIG.services.length - 1) { 37 | up.disabled = false; 38 | down.disabled = true; 39 | } else { 40 | up.disabled = false; 41 | down.disabled = false; 42 | } 43 | 44 | serviceContainer.append(el); 45 | }); 46 | gridUpdateTabs?.(); 47 | } 48 | 49 | function posCallback(el, dir) { 50 | const id = el.dataset.id; 51 | const curPos = CONFIG.services.findIndex((val) => val === id); 52 | const newPos = curPos + dir; 53 | 54 | if (CONFIG.services.length > 1) { 55 | const temp = CONFIG.services[newPos]; 56 | CONFIG.services[newPos] = CONFIG.services[curPos]; 57 | CONFIG.services[curPos] = temp; 58 | } 59 | 60 | localStorage.setItem("reddit:services", JSON.stringify(CONFIG.services)); 61 | 62 | stackServiceElements(); 63 | } 64 | 65 | function removeCallback(el) { 66 | const id = el.dataset.id; 67 | CONFIG.services = CONFIG.services.filter((s) => s !== id); 68 | CONFIG.servicesElement[id].remove(); 69 | 70 | localStorage.setItem("reddit:services", JSON.stringify(CONFIG.services)); 71 | 72 | stackServiceElements(); 73 | } 74 | 75 | for (const name of CONFIG.services) { 76 | CONFIG.servicesElement[name] = createServiceOption(name, posCallback, removeCallback); 77 | } 78 | stackServiceElements(); 79 | 80 | const serviceInput = document.createElement("input"); 81 | serviceInput.placeholder = "Add new subreddit"; 82 | serviceInput.onkeydown = (event) => { 83 | if (event.key !== "Enter") { 84 | return; 85 | } 86 | event.preventDefault(); 87 | const name = serviceInput.value; 88 | 89 | if (!CONFIG.services.includes(name)) { 90 | CONFIG.services.push(name); 91 | CONFIG.servicesElement[name] = createServiceOption(name, posCallback, removeCallback); 92 | localStorage.setItem("reddit:services", JSON.stringify(CONFIG.services)); 93 | } 94 | 95 | stackServiceElements(); 96 | serviceInput.value = ""; 97 | const parent = configContainer.parentElement.parentElement; 98 | parent.scrollTo(0, parent.scrollHeight); 99 | }; 100 | 101 | configContainer.append( 102 | optionHeader, 103 | createSlider("Upvotes count", "upvotes"), 104 | createSlider("Followers count", "followers"), 105 | createSlider("Post type", "type"), 106 | createSlider("Long description", "longDescription"), 107 | serviceHeader, 108 | serviceContainer, 109 | serviceInput 110 | ); 111 | 112 | Spicetify.PopupModal.display({ 113 | title: "Reddit", 114 | content: configContainer, 115 | }); 116 | } 117 | 118 | function createSlider(name, key) { 119 | const container = document.createElement("div"); 120 | container.innerHTML = ` 121 |
122 | 123 |
128 |
`; 129 | 130 | const slider = container.querySelector("button"); 131 | slider.classList.toggle("disabled", !CONFIG.visual[key]); 132 | 133 | slider.onclick = () => { 134 | const state = !slider.classList.toggle("disabled"); 135 | CONFIG.visual[key] = state; 136 | localStorage.setItem(`reddit:${key}`, String(state)); 137 | gridUpdatePostsVisual?.(); 138 | }; 139 | 140 | return container; 141 | } 142 | 143 | function createServiceOption(id, posCallback, removeCallback) { 144 | const container = document.createElement("div"); 145 | container.dataset.id = id; 146 | container.innerHTML = ` 147 |
148 |

${id}

149 |
150 | 155 | 160 | 165 |
166 |
`; 167 | 168 | const [up, down, remove] = container.querySelectorAll("button"); 169 | 170 | up.onclick = () => posCallback(container, -1); 171 | down.onclick = () => posCallback(container, 1); 172 | remove.onclick = () => removeCallback(container); 173 | 174 | return container; 175 | } 176 | -------------------------------------------------------------------------------- /CustomApps/reddit/SortBox.js: -------------------------------------------------------------------------------- 1 | class SortBox extends react.Component { 2 | constructor(props) { 3 | super(props); 4 | this.sortByOptions = [ 5 | { key: "hot", value: "Hot" }, 6 | { key: "new", value: "New" }, 7 | { key: "top", value: "Top" }, 8 | { key: "rising", value: "Rising" }, 9 | { key: "controversial", value: "Controversial" }, 10 | ]; 11 | this.sortTimeOptions = [ 12 | { key: "hour", value: "Hour" }, 13 | { key: "day", value: "Day" }, 14 | { key: "week", value: "Week" }, 15 | { key: "month", value: "Month" }, 16 | { key: "year", value: "Year" }, 17 | { key: "all", value: "All" }, 18 | ]; 19 | } 20 | 21 | render() { 22 | const sortBySelected = this.sortByOptions.filter((a) => a.key === sortConfig.by)[0]; 23 | const sortTimeSelected = this.sortTimeOptions.filter((a) => a.key === sortConfig.time)[0]; 24 | 25 | return react.createElement( 26 | "div", 27 | { 28 | className: "reddit-sort-bar", 29 | }, 30 | react.createElement( 31 | "div", 32 | { 33 | className: "reddit-sort-container", 34 | }, 35 | react.createElement(OptionsMenu, { 36 | options: this.sortByOptions, 37 | onSelect: (by) => this.props.onChange(by, null), 38 | selected: sortBySelected, 39 | }), 40 | !!sortConfig.by.match(/top|controversial/) && 41 | react.createElement(OptionsMenu, { 42 | options: this.sortTimeOptions, 43 | onSelect: (time) => this.props.onChange(null, time), 44 | selected: sortTimeSelected, 45 | }) 46 | ) 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /CustomApps/reddit/TabBar.js: -------------------------------------------------------------------------------- 1 | class TabBarItem extends react.Component { 2 | render() { 3 | return react.createElement( 4 | "li", 5 | { 6 | className: "reddit-tabBar-headerItem", 7 | onClick: (event) => { 8 | event.preventDefault(); 9 | this.props.switchTo(this.props.item.key); 10 | }, 11 | }, 12 | react.createElement( 13 | "a", 14 | { 15 | "aria-current": "page", 16 | className: `reddit-tabBar-headerItemLink ${this.props.item.active ? "reddit-tabBar-active" : ""}`, 17 | draggable: "false", 18 | href: "", 19 | }, 20 | react.createElement( 21 | "span", 22 | { 23 | className: "main-type-mestoBold", 24 | }, 25 | this.props.item.value 26 | ) 27 | ) 28 | ); 29 | } 30 | } 31 | 32 | const TabBarMore = react.memo(({ items, switchTo }) => { 33 | const activeItem = items.find((item) => item.active); 34 | 35 | return react.createElement( 36 | "li", 37 | { 38 | className: `reddit-tabBar-headerItem ${activeItem ? "reddit-tabBar-active" : ""}`, 39 | }, 40 | react.createElement(OptionsMenu, { 41 | options: items, 42 | onSelect: switchTo, 43 | selected: activeItem, 44 | defaultValue: "More", 45 | bold: true, 46 | }) 47 | ); 48 | }); 49 | 50 | const TopBarContent = ({ links, activeLink, switchCallback }) => { 51 | const resizeHost = document.querySelector( 52 | ".Root__main-view .os-resize-observer-host, .Root__main-view .os-size-observer, .Root__main-view .main-view-container__scroll-node" 53 | ); 54 | const [windowSize, setWindowSize] = useState(resizeHost.clientWidth); 55 | const resizeHandler = () => setWindowSize(resizeHost.clientWidth); 56 | 57 | useEffect(() => { 58 | const observer = new ResizeObserver(resizeHandler); 59 | observer.observe(resizeHost); 60 | return () => { 61 | observer.disconnect(); 62 | }; 63 | }, [resizeHandler]); 64 | 65 | return react.createElement( 66 | TabBarContext, 67 | null, 68 | react.createElement(TabBar, { 69 | className: "queue-queueHistoryTopBar-tabBar", 70 | links, 71 | activeLink, 72 | windowSize, 73 | switchCallback, 74 | }) 75 | ); 76 | }; 77 | 78 | const TabBarContext = ({ children }) => { 79 | return reactDOM.createPortal( 80 | react.createElement( 81 | "div", 82 | { 83 | className: "main-topBar-topbarContent", 84 | }, 85 | children 86 | ), 87 | document.querySelector(".main-topBar-topbarContentWrapper") 88 | ); 89 | }; 90 | 91 | const TabBar = react.memo(({ links, activeLink, switchCallback, windowSize = Number.POSITIVE_INFINITY }) => { 92 | const tabBarRef = react.useRef(null); 93 | const [childrenSizes, setChildrenSizes] = useState([]); 94 | const [availableSpace, setAvailableSpace] = useState(0); 95 | const [droplistItem, setDroplistItems] = useState([]); 96 | 97 | const options = links.map((key) => { 98 | const active = key === activeLink; 99 | return { key, value: key, active }; 100 | }); 101 | 102 | useEffect(() => { 103 | if (!tabBarRef.current) return; 104 | setAvailableSpace(tabBarRef.current.clientWidth); 105 | }, [windowSize]); 106 | 107 | useEffect(() => { 108 | if (!tabBarRef.current) return; 109 | 110 | const children = Array.from(tabBarRef.current.children); 111 | const tabbarItemSizes = children.map((child) => child.clientWidth); 112 | 113 | setChildrenSizes(tabbarItemSizes); 114 | }, [links]); 115 | 116 | useEffect(() => { 117 | if (!tabBarRef.current) return; 118 | 119 | const totalSize = childrenSizes.reduce((a, b) => a + b, 0); 120 | 121 | // Can we render everything? 122 | if (totalSize <= availableSpace) { 123 | setDroplistItems([]); 124 | return; 125 | } 126 | 127 | // The `More` button can be set to _any_ of the children. So we 128 | // reserve space for the largest item instead of always taking 129 | // the last item. 130 | const viewMoreButtonSize = Math.max(...childrenSizes); 131 | 132 | // Figure out how many children we can render while also showing 133 | // the More button 134 | const itemsToHide = []; 135 | let stopWidth = viewMoreButtonSize; 136 | 137 | childrenSizes.forEach((childWidth, i) => { 138 | if (availableSpace >= stopWidth + childWidth) { 139 | stopWidth += childWidth; 140 | } else { 141 | itemsToHide.push(i); 142 | } 143 | }); 144 | 145 | setDroplistItems(itemsToHide); 146 | }, [availableSpace, childrenSizes]); 147 | 148 | return react.createElement( 149 | "nav", 150 | { 151 | className: "reddit-tabBar reddit-tabBar-nav", 152 | }, 153 | react.createElement( 154 | "ul", 155 | { 156 | className: "reddit-tabBar-header", 157 | ref: tabBarRef, 158 | }, 159 | options 160 | .filter((_, id) => !droplistItem.includes(id)) 161 | .map((item) => 162 | react.createElement(TabBarItem, { 163 | item, 164 | switchTo: switchCallback, 165 | }) 166 | ), 167 | droplistItem.length || childrenSizes.length === 0 168 | ? react.createElement(TabBarMore, { 169 | items: droplistItem.map((i) => options[i]).filter(Boolean), 170 | switchTo: switchCallback, 171 | }) 172 | : null 173 | ) 174 | ); 175 | }); 176 | -------------------------------------------------------------------------------- /CustomApps/reddit/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Reddit", 3 | "icon": "", 4 | "active-icon": "", 5 | "subfiles": ["Card.js", "Icons.js", "OptionsMenu.js", "SortBox.js", "TabBar.js", "Settings.js"] 6 | } 7 | -------------------------------------------------------------------------------- /CustomApps/reddit/style.css: -------------------------------------------------------------------------------- 1 | .setting-row::after { 2 | content: ""; 3 | display: table; 4 | clear: both; 5 | } 6 | .setting-row .col { 7 | display: flex; 8 | padding: 10px 0; 9 | align-items: center; 10 | } 11 | .setting-row .col.description { 12 | float: left; 13 | padding-right: 15px; 14 | cursor: default; 15 | } 16 | .setting-row .col.action { 17 | float: right; 18 | text-align: right; 19 | } 20 | button.switch { 21 | align-items: center; 22 | border: 0px; 23 | border-radius: 50%; 24 | background-color: rgba(var(--spice-rgb-shadow), 0.7); 25 | color: var(--spice-text); 26 | cursor: pointer; 27 | display: flex; 28 | margin-inline-start: 12px; 29 | padding: 8px; 30 | } 31 | button.switch.disabled, 32 | button.switch[disabled] { 33 | color: rgba(var(--spice-rgb-text), 0.3); 34 | } 35 | button.switch.small { 36 | width: 22px; 37 | height: 22px; 38 | padding: 6px; 39 | } 40 | .reddit-sort-container .optionsMenu-dropBox { 41 | grid-gap: 8px; 42 | align-items: center; 43 | background-color: transparent; 44 | border-radius: 4px; 45 | display: grid; 46 | grid-template-columns: 1fr 16px; 47 | color: rgba(var(--spice-rgb-text), 0.7); 48 | border: 0; 49 | height: 32px; 50 | margin-left: 8px; 51 | padding: 0 8px 0 12px; 52 | } 53 | .reddit-sort-container .optionsMenu-dropBox:hover { 54 | color: var(--spice-text); 55 | } 56 | #reddit-config-container input { 57 | width: 100%; 58 | margin-top: 10px; 59 | padding: 0 5px; 60 | height: 32px; 61 | border: 0; 62 | color: var(--spice-text); 63 | background-color: initial; 64 | border-bottom: 1px solid var(--spice-text); 65 | } 66 | 67 | option { 68 | background-color: var(--spice-button); 69 | } 70 | 71 | .reddit-header { 72 | -webkit-box-pack: justify; 73 | -webkit-box-align: center; 74 | align-content: space-between; 75 | align-items: center; 76 | color: var(--spice-text); 77 | display: flex; 78 | justify-content: space-between; 79 | margin: 16px 0; 80 | } 81 | 82 | /* New layout top bar height = 64px + Original margin = 16px */ 83 | .Root__fixed-top-bar ~ .Root__main-view .reddit-header { 84 | margin-top: 80px; 85 | } 86 | 87 | .reddit-sort-bar { 88 | align-items: center; 89 | display: flex; 90 | } 91 | 92 | .reddit-sort-container { 93 | position: relative; 94 | display: flex; 95 | } 96 | 97 | .reddit-tabBar-headerItem { 98 | -webkit-app-region: no-drag; 99 | display: inline-block; 100 | pointer-events: auto; 101 | } 102 | 103 | .reddit-tabBar-headerItemLink { 104 | margin: 0 8px 0 0; 105 | } 106 | 107 | .reddit-tabBar-active { 108 | background-color: var(--spice-tab-active); 109 | border-radius: 4px; 110 | } 111 | 112 | .reddit-tabBar-headerItemLink { 113 | border-radius: 4px; 114 | color: var(--spice-text); 115 | display: inline-block; 116 | margin: 0 8px; 117 | padding: 8px 16px; 118 | position: relative; 119 | text-decoration: none !important; 120 | cursor: pointer; 121 | } 122 | 123 | .reddit-tabBar-nav { 124 | -webkit-app-region: drag; 125 | pointer-events: none; 126 | width: 100%; 127 | } 128 | 129 | .reddit-tabBar-headerItem .optionsMenu-dropBox { 130 | color: var(--spice-text); 131 | border: 0; 132 | max-width: 150px; 133 | height: 42px; 134 | padding: 0 30px 0 12px; 135 | background-color: initial; 136 | cursor: pointer; 137 | appearance: none; 138 | } 139 | 140 | .reddit-tabBar-headerItem .optionsMenu-dropBox svg { 141 | position: absolute; 142 | margin-left: 8px; 143 | } 144 | 145 | div.reddit-tabBar-headerItemLink { 146 | padding: 0; 147 | } 148 | 149 | .reddit-cardSubHeader { 150 | margin-top: 4px; 151 | white-space: normal; 152 | color: var(--spice-subtext); 153 | } 154 | 155 | .reddit-longDescription { 156 | display: flex; 157 | } 158 | .reddit-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton button { 159 | -webkit-tap-highlight-color: transparent; 160 | background-color: transparent; 161 | border: 0px; 162 | border-radius: 500px; 163 | display: inline-block; 164 | position: relative; 165 | touch-action: manipulation; 166 | transition-duration: 33ms; 167 | transition-property: background-color, border-color, color, box-shadow, filter, transform; 168 | user-select: none; 169 | vertical-align: middle; 170 | transform: translate3d(0px, 0px, 0px); 171 | padding: 0px; 172 | min-inline-size: 0px; 173 | align-self: center; 174 | } 175 | .reddit-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton span { 176 | -webkit-tap-highlight-color: transparent; 177 | position: relative; 178 | background-color: var(--spice-button-active); 179 | color: var(--spice-sidebar); 180 | display: flex; 181 | border-radius: 500px; 182 | font-size: inherit; 183 | min-block-size: 48px; 184 | -webkit-box-align: center; 185 | align-items: center; 186 | -webkit-box-pack: center; 187 | justify-content: center; 188 | inline-size: 48px; 189 | block-size: 48px; 190 | } 191 | .reddit-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton span:hover { 192 | transform: scale(1.04); 193 | } 194 | -------------------------------------------------------------------------------- /Extensions/autoSkipExplicit.js: -------------------------------------------------------------------------------- 1 | // NAME: Christian Spotify 2 | // AUTHOR: khanhas 3 | // DESCRIPTION: Auto skip explicit songs. Toggle in Profile menu. 4 | 5 | /// 6 | 7 | (function ChristianSpotify() { 8 | if (!Spicetify.LocalStorage) { 9 | setTimeout(ChristianSpotify, 1000); 10 | return; 11 | } 12 | 13 | let isEnabled = Spicetify.LocalStorage.get("ChristianMode") === "1"; 14 | 15 | new Spicetify.Menu.Item("Christian mode", isEnabled, (self) => { 16 | isEnabled = !isEnabled; 17 | Spicetify.LocalStorage.set("ChristianMode", isEnabled ? "1" : "0"); 18 | self.setState(isEnabled); 19 | }).register(); 20 | 21 | Spicetify.Player.addEventListener("songchange", () => { 22 | if (!isEnabled) return; 23 | const data = Spicetify.Player.data || Spicetify.Queue; 24 | if (!data) return; 25 | 26 | const isExplicit = data.item.metadata.is_explicit; 27 | if (isExplicit === "true") { 28 | Spicetify.Player.next(); 29 | } 30 | }); 31 | })(); 32 | -------------------------------------------------------------------------------- /Extensions/autoSkipVideo.js: -------------------------------------------------------------------------------- 1 | // NAME: Auto Skip Video 2 | // AUTHOR: khanhas 3 | // DESCRIPTION: Auto skip video 4 | 5 | /// 6 | 7 | (function SkipVideo() { 8 | Spicetify.Player.addEventListener("songchange", () => { 9 | const data = Spicetify.Player.data || Spicetify.Queue; 10 | if (!data) return; 11 | 12 | const meta = data.item.metadata; 13 | // Ads are also video media type so I need to exclude them out. 14 | if (meta["media.type"] === "video" && meta.is_advertisement !== "true") { 15 | Spicetify.Player.next(); 16 | } 17 | }); 18 | })(); 19 | -------------------------------------------------------------------------------- /Extensions/loopyLoop.js: -------------------------------------------------------------------------------- 1 | // NAME: Loopy loop 2 | // AUTHOR: khanhas 3 | // VERSION: 0.1 4 | // DESCRIPTION: Simple tool to help you practice hitting that note right. Right click at process bar to open up menu. 5 | 6 | /// 7 | 8 | (function LoopyLoop() { 9 | const bar = document.querySelector(".playback-bar .progress-bar"); 10 | if (!(bar && Spicetify.React)) { 11 | setTimeout(LoopyLoop, 100); 12 | return; 13 | } 14 | 15 | const style = document.createElement("style"); 16 | style.innerHTML = ` 17 | #loopy-loop-start, #loopy-loop-end { 18 | position: absolute; 19 | font-weight: bolder; 20 | font-size: 15px; 21 | top: -7px; 22 | } 23 | `; 24 | 25 | const startMark = document.createElement("div"); 26 | startMark.id = "loopy-loop-start"; 27 | startMark.innerText = "["; 28 | const endMark = document.createElement("div"); 29 | endMark.id = "loopy-loop-end"; 30 | endMark.innerText = "]"; 31 | startMark.style.position = endMark.style.position = "absolute"; 32 | startMark.hidden = endMark.hidden = true; 33 | 34 | bar.append(style); 35 | bar.append(startMark); 36 | bar.append(endMark); 37 | 38 | let start = null; 39 | let end = null; 40 | let mouseOnBarPercent = 0.0; 41 | 42 | function drawOnBar() { 43 | if (start === null && end === null) { 44 | startMark.hidden = endMark.hidden = true; 45 | return; 46 | } 47 | startMark.hidden = endMark.hidden = false; 48 | startMark.style.left = `${start * 100}%`; 49 | endMark.style.left = `${end * 100}%`; 50 | } 51 | function reset() { 52 | start = null; 53 | end = null; 54 | drawOnBar(); 55 | } 56 | 57 | let debouncing = 0; 58 | Spicetify.Player.addEventListener("onprogress", (event) => { 59 | if (start != null && end != null) { 60 | if (debouncing) { 61 | if (event.timeStamp - debouncing > 1000) { 62 | debouncing = 0; 63 | } 64 | return; 65 | } 66 | const percent = Spicetify.Player.getProgressPercent(); 67 | if (percent > end || percent < start) { 68 | debouncing = event.timeStamp; 69 | Spicetify.Player.seek(start); 70 | return; 71 | } 72 | } 73 | }); 74 | 75 | Spicetify.Player.addEventListener("songchange", reset); 76 | 77 | function createMenuItem(title, callback) { 78 | const wrapper = document.createElement("div"); 79 | Spicetify.ReactDOM.render( 80 | Spicetify.React.createElement( 81 | Spicetify.ReactComponent.MenuItem, 82 | { 83 | onClick: () => { 84 | contextMenu.hidden = true; 85 | callback?.(); 86 | }, 87 | }, 88 | title 89 | ), 90 | wrapper 91 | ); 92 | 93 | return wrapper; 94 | } 95 | 96 | const startBtn = createMenuItem("Set start", () => { 97 | start = mouseOnBarPercent; 98 | if (end === null || start > end) { 99 | end = 0.99; 100 | } 101 | drawOnBar(); 102 | }); 103 | const endBtn = createMenuItem("Set end", () => { 104 | end = mouseOnBarPercent; 105 | if (start === null || end < start) { 106 | start = 0; 107 | } 108 | drawOnBar(); 109 | }); 110 | const resetBtn = createMenuItem("Reset", reset); 111 | 112 | const contextMenu = document.createElement("div"); 113 | contextMenu.id = "loopy-context-menu"; 114 | contextMenu.innerHTML = `
    `; 115 | contextMenu.style.position = "absolute"; 116 | contextMenu.firstElementChild.append(startBtn, endBtn, resetBtn); 117 | document.body.append(contextMenu); 118 | const { height: contextMenuHeight } = contextMenu.getBoundingClientRect(); 119 | contextMenu.hidden = true; 120 | window.addEventListener("click", () => { 121 | contextMenu.hidden = true; 122 | }); 123 | 124 | bar.oncontextmenu = (event) => { 125 | const { x, width } = bar.firstElementChild.getBoundingClientRect(); 126 | mouseOnBarPercent = (event.clientX - x) / width; 127 | contextMenu.style.transform = `translate(${event.clientX}px,${event.clientY - contextMenuHeight}px)`; 128 | contextMenu.hidden = false; 129 | event.preventDefault(); 130 | }; 131 | })(); 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

    2 |

    3 | 4 | 5 | 6 | 7 | 8 |

    9 | 10 | --- 11 | 12 | Command-line tool to customize the official Spotify client. 13 | Supports Windows, MacOS and Linux. 14 | 15 | img 16 | 17 | ### Features 18 | 19 | - Change colors whole UI 20 | - Inject CSS for advanced customization 21 | - Inject Extensions (Javascript script) to extend functionalities, manipulate UI and control player. 22 | - Inject Custom apps 23 | - Remove bloated components to improve performance 24 | 25 | ### Links 26 | 27 | - [Installation](https://spicetify.app/docs/getting-started) 28 | - [Basic Usage](https://spicetify.app/docs/getting-started#basic-usage) 29 | - [FAQ](https://spicetify.app/docs/faq) 30 | 31 | ### Code Signing Policy 32 | 33 | Free code signing provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org/). 34 | -------------------------------------------------------------------------------- /Themes/SpicetifyDefault/color.ini: -------------------------------------------------------------------------------- 1 | ; COLORS KEYS DESCRIPTION 2 | ; text = Main field text; playlist names in main field and sidebar; headings. 3 | ; subtext = Text in main sidebar buttons; playlist names in sidebar; artist names and mini infos. 4 | ; main = Main field background. 5 | ; main-elevated = Backgrounds for objects above the main field. 6 | ; highlight = Highlight background for hovering over objects. 7 | ; highlight-elevated = Highlight colors for objects above the main field. 8 | ; sidebar = Sidebar background. 9 | ; player = Player background. 10 | ; card = Card background on hover; player area outline. 11 | ; shadow = Card drop shadow; button background. 12 | ; selected row = Color of selected song, scrollbar, caption and playlist details, download and options buttons. 13 | ; button = Playlist button background in sidebar; drop-down menus; now playing song; play button background; like button. 14 | ; button-active = Active play button background. 15 | ; button-disabled = Seekbar and volume bar background. 16 | ; tab-active = Tabbar active item background in header. 17 | ; notification = Notification toast. 18 | ; notification-error = Error notification toast. 19 | ; misc = Miscellaneous. 20 | 21 | [green-dark] 22 | ; Light green on Dark Blue background 23 | text = FFFFFF 24 | subtext = DEDEDE 25 | main = 2E2837 26 | sidebar = 2E2837 27 | player = 2E2837 28 | card = 483b5b 29 | shadow = 202020 30 | selected-row = cdcdcd 31 | button = 00e089 32 | button-active = 483b5b 33 | button-disabled = 535353 34 | tab-active = 483b5b 35 | notification = 00e089 36 | notification-error = e22134 37 | misc = BFBFBF 38 | 39 | [nord-light] 40 | text = 2E3440 41 | subtext = 3b4252 42 | main = ECEFF4 43 | sidebar = ECEFF4 44 | player = e5e9f0 45 | card = 88c0d0 46 | shadow = eceff4 47 | selected-row = 9ea4af 48 | button = 88c0d0 49 | button-active = d8dee9 50 | button-disabled = c0c0c0 51 | tab-active = d8dee9 52 | notification = 88c0d0 53 | notification-error = e22134 54 | misc = BFBFBF 55 | 56 | [nord-dark] 57 | text = D8DEE9 58 | subtext = ECEFF4 59 | main = 2e3440 60 | sidebar = 2e3440 61 | player = 2e3440 62 | card = 3b4252 63 | shadow = 4c566a 64 | selected-row = e5e9f0 65 | button = 5E81AC 66 | button-active = 434c5e 67 | button-disabled = 434c5e 68 | tab-active = 434c5e 69 | notification = 5E81AC 70 | notification-error = e22134 71 | misc = BFBFBF 72 | 73 | [pink-white] 74 | ; Pink on White background 75 | text = 000000 76 | subtext = 3D3D3D 77 | main = FAFAFA 78 | sidebar = FAFAFA 79 | player = FAFAFA 80 | card = FE6F61 81 | shadow = F0F0F0 82 | selected-row = 404040 83 | button = FE6F61 84 | button-active = e9e9e9 85 | button-disabled = 535353 86 | tab-active = e9e9e9 87 | notification = FE6F61 88 | notification-error = e22134 89 | misc = BFBFBF 90 | 91 | [purple] 92 | text = FFFFFF 93 | subtext = F0F0F0 94 | main = 0A0E14 95 | sidebar = 0A0E14 96 | player = 0A0E14 97 | card = 6F3C89 98 | shadow = 1f1525 99 | selected-row = 909090 100 | button = 6F3C89 101 | button-active = 795b84 102 | button-disabled = 535353 103 | tab-active = 795b84 104 | notification = 6F3C89 105 | notification-error = e22134 106 | misc = BFBFBF 107 | 108 | [dracula] 109 | text = FFFFFF 110 | subtext = d8dee9 111 | main = 282a36 112 | sidebar = 282a36 113 | player = 282a36 114 | card = 6272a4 115 | shadow = 44475a 116 | selected-row = F0F0F0 117 | button = ffb86c 118 | button-active = 44475a 119 | button-disabled = 535353 120 | tab-active = 44475a 121 | notification = ffb86c 122 | notification-error = e22134 123 | misc = BFBFBF 124 | 125 | -------------------------------------------------------------------------------- /Themes/SpicetifyDefault/user.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --player-bar-height: 105px; 3 | } 4 | 5 | .main-rootlist-rootlistDividerGradient { 6 | background: unset; 7 | } 8 | 9 | input { 10 | background-color: unset !important; 11 | border-bottom: solid 1px var(--spice-text) !important; 12 | border-radius: 0 !important; 13 | padding: 6px 10px 6px 48px; 14 | color: var(--spice-text) !important; 15 | } 16 | 17 | .x-searchInput-searchInputSearchIcon, 18 | .x-searchInput-searchInputClearButton { 19 | color: var(--spice-text) !important; 20 | } 21 | 22 | .main-home-homeHeader, 23 | .x-entityHeader-overlay, 24 | .x-actionBarBackground-background, 25 | .main-actionBarBackground-background, 26 | .main-entityHeader-overlay, 27 | .main-entityHeader-backgroundColor { 28 | background-color: unset !important; 29 | background-image: unset !important; 30 | } 31 | 32 | .main-playButton-PlayButton.main-playButton-primary { 33 | color: white; 34 | } 35 | 36 | .connect-title, 37 | .connect-header { 38 | display: none; 39 | } 40 | 41 | .connect-device-list { 42 | margin: 0px -5px; 43 | } 44 | 45 | /* Remove Topbar background colour */ 46 | .main-topBar-background { 47 | background-color: unset !important; 48 | } 49 | .main-topBar-overlay { 50 | background-color: var(--spice-main); 51 | } 52 | 53 | .main-entityHeader-shadow, 54 | .connect-device-list-container { 55 | box-shadow: 0 4px 20px rgba(var(--spice-rgb-shadow), 0.2); 56 | } 57 | 58 | .main-trackList-playingIcon { 59 | filter: grayscale(1); 60 | } 61 | 62 | .main-trackList-trackListRow.main-trackList-active:hover .main-trackList-rowMarker, 63 | .main-trackList-trackListRow.main-trackList-active:hover .main-trackList-rowTitle, 64 | .main-trackList-trackListRow.main-trackList-active:focus-within .main-trackList-rowMarker, 65 | .main-trackList-trackListRow.main-trackList-active:focus-within .main-trackList-rowTitle { 66 | color: var(--spice-button); 67 | } 68 | 69 | .main-entityHeader-metaDataText, 70 | .main-duration-container { 71 | color: var(--spice-subtext); 72 | } 73 | 74 | span.artist-artistVerifiedBadge-badge svg:nth-child(1) { 75 | fill: black; 76 | } 77 | 78 | /* Full window artist background */ 79 | .main-entityHeader-background.main-entityHeader-gradient { 80 | opacity: 0.3; 81 | } 82 | 83 | .main-entityHeader-container.main-entityHeader-withBackgroundImage, 84 | .main-entityHeader-background, 85 | .main-entityHeader-background.main-entityHeader-overlay:after { 86 | height: 100vh; 87 | } 88 | 89 | .main-entityHeader-withBackgroundImage .main-entityHeader-headerText { 90 | justify-content: center; 91 | } 92 | 93 | .main-entityHeader-container.main-entityHeader-nonWrapped.main-entityHeader-withBackgroundImage { 94 | padding-left: 9%; 95 | } 96 | 97 | .main-entityHeader-background.main-entityHeader-overlay:after { 98 | background-image: linear-gradient(transparent, transparent), linear-gradient(var(--spice-main), var(--spice-main)); 99 | } 100 | 101 | .artist-artistOverview-overview .main-entityHeader-withBackgroundImage h1 { 102 | font-size: 175px !important; 103 | line-height: 175px !important; 104 | } 105 | 106 | /** Hightlight selected playlist */ 107 | .main-rootlist-rootlistItemLink.main-rootlist-rootlistItemLinkActive { 108 | background: var(--spice-button); 109 | border-radius: 4px; 110 | padding: 0 10px; 111 | margin: 0 5px 0 -10px; 112 | } 113 | 114 | .main-navBar-navBarLinkActive { 115 | background: var(--spice-button); 116 | } 117 | 118 | div.GlueDropTarget.personal-library > *.active { 119 | background: var(--spice-button) !important; 120 | } 121 | 122 | .main-contextMenu-menu { 123 | background-color: var(--spice-button-active); 124 | } 125 | 126 | .main-contextMenu-menuHeading, 127 | .main-contextMenu-menuItemButton, 128 | .main-contextMenu-menuItemButton:not(.main-contextMenu-disabled):focus, 129 | .main-contextMenu-menuItemButton:not(.main-contextMenu-disabled):hover { 130 | color: var(--spice-text); 131 | } 132 | 133 | .main-playPauseButton-button { 134 | background-color: var(--spice-button); 135 | color: white; 136 | } 137 | 138 | /** Queue page header */ 139 | .queue-queue-title, 140 | .queue-playHistory-title { 141 | color: var(--spice-text) !important; 142 | } 143 | 144 | /** Cards */ 145 | .main-cardImage-imageWrapper { 146 | background-color: transparent; 147 | } 148 | 149 | /** Sidebar */ 150 | .main-rootlist-rootlistDivider { 151 | margin-bottom: 8px; 152 | } 153 | 154 | .main-rootlist-rootlistPlaylistsScrollNode { 155 | padding: 0; 156 | } 157 | 158 | #spicetify-playlist-list { 159 | padding-top: 8px; 160 | } 161 | .main-collectionLinkButton-collectionLinkButton .main-collectionLinkButton-icon, 162 | .main-collectionLinkButton-collectionLinkButton .main-collectionLinkButton-collectionLinkText { 163 | opacity: 1; 164 | } 165 | 166 | .link-subtle { 167 | color: var(--spice-text); 168 | } 169 | 170 | /** Player bar */ 171 | .main-nowPlayingBar-nowPlayingBar { 172 | height: var(--player-bar-height); 173 | } 174 | 175 | /** Buddy bar */ 176 | .main-buddyFeed-activityMetadata .main-buddyFeed-artistAndTrackName a, 177 | .main-buddyFeed-activityMetadata .main-buddyFeed-username a, 178 | .main-buddyFeed-activityMetadata .main-buddyFeed-playbackContextLink { 179 | color: var(--spice-text); 180 | } 181 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "suspicious": { 11 | "noExplicitAny": "off" 12 | } 13 | } 14 | }, 15 | "formatter": { 16 | "enabled": true, 17 | "formatWithErrors": true, 18 | "indentStyle": "tab", 19 | "indentWidth": 2, 20 | "lineWidth": 150 21 | }, 22 | "javascript": { 23 | "formatter": { 24 | "trailingCommas": "es5", 25 | "arrowParentheses": "always" 26 | } 27 | }, 28 | "vcs": { 29 | "enabled": true, 30 | "clientKind": "git", 31 | "useIgnoreFile": true, 32 | "defaultBranch": "main" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/spicetify/cli 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/go-ini/ini v1.67.0 7 | github.com/mattn/go-colorable v0.1.14 8 | github.com/pterm/pterm v0.12.80 9 | golang.org/x/net v0.40.0 10 | golang.org/x/sys v0.33.0 11 | ) 12 | 13 | require ( 14 | atomicgo.dev/cursor v0.2.0 // indirect 15 | atomicgo.dev/keyboard v0.2.9 // indirect 16 | atomicgo.dev/schedule v0.1.0 // indirect 17 | github.com/containerd/console v1.0.3 // indirect 18 | github.com/gookit/color v1.5.4 // indirect 19 | github.com/lithammer/fuzzysearch v1.1.8 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/mattn/go-runewidth v0.0.16 // indirect 22 | github.com/rivo/uniseg v0.4.7 // indirect 23 | github.com/stretchr/testify v1.9.0 // indirect 24 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 25 | golang.org/x/term v0.32.0 // indirect 26 | golang.org/x/text v0.25.0 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = 'Stop' 2 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 3 | 4 | #region Variables 5 | $spicetifyFolderPath = "$env:LOCALAPPDATA\spicetify" 6 | $spicetifyOldFolderPath = "$HOME\spicetify-cli" 7 | #endregion Variables 8 | 9 | #region Functions 10 | function Write-Success { 11 | [CmdletBinding()] 12 | param () 13 | process { 14 | Write-Host -Object ' > OK' -ForegroundColor 'Green' 15 | } 16 | } 17 | 18 | function Write-Unsuccess { 19 | [CmdletBinding()] 20 | param () 21 | process { 22 | Write-Host -Object ' > ERROR' -ForegroundColor 'Red' 23 | } 24 | } 25 | 26 | function Test-Admin { 27 | [CmdletBinding()] 28 | param () 29 | begin { 30 | Write-Host -Object "Checking if the script is not being run as administrator..." -NoNewline 31 | } 32 | process { 33 | $currentUser = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) 34 | -not $currentUser.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) 35 | } 36 | } 37 | 38 | function Test-PowerShellVersion { 39 | [CmdletBinding()] 40 | param () 41 | begin { 42 | $PSMinVersion = [version]'5.1' 43 | } 44 | process { 45 | Write-Host -Object 'Checking if your PowerShell version is compatible...' -NoNewline 46 | $PSVersionTable.PSVersion -ge $PSMinVersion 47 | } 48 | } 49 | 50 | function Move-OldSpicetifyFolder { 51 | [CmdletBinding()] 52 | param () 53 | process { 54 | if (Test-Path -Path $spicetifyOldFolderPath) { 55 | Write-Host -Object 'Moving the old spicetify folder...' -NoNewline 56 | Copy-Item -Path "$spicetifyOldFolderPath\*" -Destination $spicetifyFolderPath -Recurse -Force 57 | Remove-Item -Path $spicetifyOldFolderPath -Recurse -Force 58 | Write-Success 59 | } 60 | } 61 | } 62 | 63 | function Get-Spicetify { 64 | [CmdletBinding()] 65 | param () 66 | begin { 67 | if ($env:PROCESSOR_ARCHITECTURE -eq 'AMD64') { 68 | $architecture = 'x64' 69 | } 70 | elseif ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 71 | $architecture = 'arm64' 72 | } 73 | else { 74 | $architecture = 'x32' 75 | } 76 | if ($v) { 77 | if ($v -match '^\d+\.\d+\.\d+$') { 78 | $targetVersion = $v 79 | } 80 | else { 81 | Write-Warning -Message "You have specified an invalid spicetify version: $v `nThe version must be in the following format: 1.2.3" 82 | Pause 83 | exit 84 | } 85 | } 86 | else { 87 | Write-Host -Object 'Fetching the latest spicetify version...' -NoNewline 88 | $latestRelease = Invoke-RestMethod -Uri 'https://api.github.com/repos/spicetify/cli/releases/latest' 89 | $targetVersion = $latestRelease.tag_name -replace 'v', '' 90 | Write-Success 91 | } 92 | $archivePath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "spicetify.zip") 93 | } 94 | process { 95 | Write-Host -Object "Downloading spicetify v$targetVersion..." -NoNewline 96 | $Parameters = @{ 97 | Uri = "https://github.com/spicetify/cli/releases/download/v$targetVersion/spicetify-$targetVersion-windows-$architecture.zip" 98 | UseBasicParsin = $true 99 | OutFile = $archivePath 100 | } 101 | Invoke-WebRequest @Parameters 102 | Write-Success 103 | } 104 | end { 105 | $archivePath 106 | } 107 | } 108 | 109 | function Add-SpicetifyToPath { 110 | [CmdletBinding()] 111 | param () 112 | begin { 113 | Write-Host -Object 'Making spicetify available in the PATH...' -NoNewline 114 | $user = [EnvironmentVariableTarget]::User 115 | $path = [Environment]::GetEnvironmentVariable('PATH', $user) 116 | } 117 | process { 118 | $path = $path -replace "$([regex]::Escape($spicetifyOldFolderPath))\\*;*", '' 119 | if ($path -notlike "*$spicetifyFolderPath*") { 120 | $path = "$path;$spicetifyFolderPath" 121 | } 122 | } 123 | end { 124 | [Environment]::SetEnvironmentVariable('PATH', $path, $user) 125 | $env:PATH = $path 126 | Write-Success 127 | } 128 | } 129 | 130 | function Install-Spicetify { 131 | [CmdletBinding()] 132 | param () 133 | begin { 134 | Write-Host -Object 'Installing spicetify...' 135 | } 136 | process { 137 | $archivePath = Get-Spicetify 138 | Write-Host -Object 'Extracting spicetify...' -NoNewline 139 | Expand-Archive -Path $archivePath -DestinationPath $spicetifyFolderPath -Force 140 | Write-Success 141 | Add-SpicetifyToPath 142 | } 143 | end { 144 | Remove-Item -Path $archivePath -Force -ErrorAction 'SilentlyContinue' 145 | Write-Host -Object 'spicetify was successfully installed!' -ForegroundColor 'Green' 146 | } 147 | } 148 | #endregion Functions 149 | 150 | #region Main 151 | #region Checks 152 | if (-not (Test-PowerShellVersion)) { 153 | Write-Unsuccess 154 | Write-Warning -Message 'PowerShell 5.1 or higher is required to run this script' 155 | Write-Warning -Message "You are running PowerShell $($PSVersionTable.PSVersion)" 156 | Write-Host -Object 'PowerShell 5.1 install guide:' 157 | Write-Host -Object 'https://learn.microsoft.com/skypeforbusiness/set-up-your-computer-for-windows-powershell/download-and-install-windows-powershell-5-1' 158 | Write-Host -Object 'PowerShell 7 install guide:' 159 | Write-Host -Object 'https://learn.microsoft.com/powershell/scripting/install/installing-powershell-on-windows' 160 | Pause 161 | exit 162 | } 163 | else { 164 | Write-Success 165 | } 166 | if (-not (Test-Admin)) { 167 | Write-Unsuccess 168 | Write-Warning -Message "The script was run as administrator. This can result in problems with the installation process or unexpected behavior. Do not continue if you do not know what you are doing." 169 | $Host.UI.RawUI.Flushinputbuffer() 170 | $choices = [System.Management.Automation.Host.ChoiceDescription[]] @( 171 | (New-Object System.Management.Automation.Host.ChoiceDescription '&Yes', 'Abort installation.'), 172 | (New-Object System.Management.Automation.Host.ChoiceDescription '&No', 'Resume installation.') 173 | ) 174 | $choice = $Host.UI.PromptForChoice('', 'Do you want to abort the installation process?', $choices, 0) 175 | if ($choice -eq 0) { 176 | Write-Host -Object 'spicetify installation aborted' -ForegroundColor 'Yellow' 177 | Pause 178 | exit 179 | } 180 | } 181 | else { 182 | Write-Success 183 | } 184 | #endregion Checks 185 | 186 | #region Spicetify 187 | Move-OldSpicetifyFolder 188 | Install-Spicetify 189 | Write-Host -Object "`nRun" -NoNewline 190 | Write-Host -Object ' spicetify -h ' -NoNewline -ForegroundColor 'Cyan' 191 | Write-Host -Object 'to get started' 192 | #endregion Spicetify 193 | 194 | #region Marketplace 195 | $Host.UI.RawUI.Flushinputbuffer() 196 | $choices = [System.Management.Automation.Host.ChoiceDescription[]] @( 197 | (New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Install Spicetify Marketplace."), 198 | (New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Do not install Spicetify Marketplace.") 199 | ) 200 | $choice = $Host.UI.PromptForChoice('', "`nDo you also want to install Spicetify Marketplace? It will become available within the Spotify client, where you can easily install themes and extensions.", $choices, 0) 201 | if ($choice -eq 1) { 202 | Write-Host -Object 'spicetify Marketplace installation aborted' -ForegroundColor 'Yellow' 203 | } 204 | else { 205 | Write-Host -Object 'Starting the spicetify Marketplace installation script..' 206 | $Parameters = @{ 207 | Uri = 'https://raw.githubusercontent.com/spicetify/spicetify-marketplace/main/resources/install.ps1' 208 | UseBasicParsing = $true 209 | } 210 | Invoke-WebRequest @Parameters | Invoke-Expression 211 | } 212 | #endregion Marketplace 213 | #endregion Main 214 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # Copyright 2022 khanhas. 3 | # Copyright 2023-present Spicetify contributors. 4 | # Edited from project Denoland install script (https://github.com/denoland/deno_install) 5 | 6 | set -e 7 | 8 | for arg in "$@"; do 9 | shift 10 | case "$arg" in 11 | "--root") override_root=1 ;; 12 | *) 13 | if echo "$arg" | grep -qv "^-"; then 14 | tag="$arg" 15 | else 16 | echo "Invalid option $arg" >&2 17 | exit 1 18 | fi 19 | esac 20 | done 21 | 22 | is_root() { 23 | [ "$(id -u)" -ne 0 ] 24 | } 25 | 26 | if ! is_root && [ "${override_root:-0}" -eq 0 ]; then 27 | echo "The script was ran under sudo or as root. The script will now exit" 28 | echo "If you hadn't intended to do this, please execute the script without root access to avoid problems with spicetify" 29 | echo "To override this behavior, pass the '--root' parameter to this script" 30 | exit 31 | fi 32 | 33 | # wipe existing log 34 | > install.log : 35 | 36 | log() { 37 | echo "$1" 38 | echo "[$(date +'%H:%M:%S %Y-%m-%d')]" "$1" >> install.log 39 | } 40 | 41 | case $(uname -sm) in 42 | "Darwin x86_64") target="darwin-amd64" ;; 43 | "Darwin arm64") target="darwin-arm64" ;; 44 | "Linux x86_64") target="linux-amd64" ;; 45 | "Linux aarch64") target="linux-arm64" ;; 46 | *) log "Unsupported platform $(uname -sm). x86_64 and arm64 binaries for Linux and Darwin are available."; exit ;; 47 | esac 48 | 49 | # check for dependencies 50 | command -v curl >/dev/null || { log "curl isn't installed!" >&2; exit 1; } 51 | command -v tar >/dev/null || { log "tar isn't installed!" >&2; exit 1; } 52 | command -v grep >/dev/null || { log "grep isn't installed!" >&2; exit 1; } 53 | 54 | # download uri 55 | releases_uri=https://github.com/spicetify/cli/releases 56 | if [ -z "$tag" ]; then 57 | tag=$(curl -LsH 'Accept: application/json' $releases_uri/latest) 58 | tag=${tag%\,\"update_url*} 59 | tag=${tag##*tag_name\":\"} 60 | tag=${tag%\"} 61 | fi 62 | 63 | tag=${tag#v} 64 | 65 | log "FETCHING Version $tag" 66 | 67 | download_uri=$releases_uri/download/v$tag/spicetify-$tag-$target.tar.gz 68 | 69 | # locations 70 | spicetify_install="$HOME/.spicetify" 71 | exe="$spicetify_install/spicetify" 72 | tar="$spicetify_install/spicetify.tar.gz" 73 | 74 | # installing 75 | [ ! -d "$spicetify_install" ] && log "CREATING $spicetify_install" && mkdir -p "$spicetify_install" 76 | 77 | log "DOWNLOADING $download_uri" 78 | curl --fail --location --progress-bar --output "$tar" "$download_uri" 79 | 80 | log "EXTRACTING $tar" 81 | tar xzf "$tar" -C "$spicetify_install" 82 | 83 | log "SETTING EXECUTABLE PERMISSIONS TO $exe" 84 | chmod +x "$exe" 85 | 86 | log "REMOVING $tar" 87 | rm "$tar" 88 | 89 | notfound() { 90 | cat << EOINFO 91 | Manually add the directory to your \$PATH through your shell profile 92 | export SPICETIFY_INSTALL="$spicetify_install" 93 | export PATH="\$PATH:$spicetify_install" 94 | EOINFO 95 | } 96 | 97 | endswith_newline() { 98 | [ "$(od -An -c "$1" | tail -1 | grep -o '.$')" = "\n" ] 99 | } 100 | 101 | check() { 102 | path="export PATH=\$PATH:$spicetify_install" 103 | shellrc=$HOME/$1 104 | 105 | if [ "$1" = ".zshrc" ] && [ -n "${ZDOTDIR}" ]; then 106 | shellrc=$ZDOTDIR/$1 107 | fi 108 | 109 | # Create shellrc if it doesn't exist 110 | if ! [ -f "$shellrc" ]; then 111 | log "CREATING $shellrc" 112 | touch "$shellrc" 113 | fi 114 | 115 | # Still checking again, in case touch command failed 116 | if [ -f "$shellrc" ]; then 117 | if ! grep -q "$spicetify_install" "$shellrc"; then 118 | log "APPENDING $spicetify_install to PATH in $shellrc" 119 | if ! endswith_newline "$shellrc"; then 120 | echo >> "$shellrc" 121 | fi 122 | echo "${2:-$path}" >> "$shellrc" 123 | export PATH="$spicetify_install:$PATH" 124 | else 125 | log "spicetify path already set in $shellrc, continuing..." 126 | fi 127 | else 128 | notfound 129 | fi 130 | } 131 | 132 | case $SHELL in 133 | *zsh) check ".zshrc" ;; 134 | *bash) 135 | [ -f "$HOME/.bashrc" ] && check ".bashrc" 136 | [ -f "$HOME/.bash_profile" ] && check ".bash_profile" 137 | ;; 138 | *fish) check ".config/fish/config.fish" "fish_add_path $spicetify_install" ;; 139 | *) notfound ;; 140 | esac 141 | 142 | echo 143 | log "spicetify v$tag was installed successfully to $spicetify_install" 144 | log "Run 'spicetify --help' to get started" 145 | 146 | echo "Do you want to install spicetify Marketplace? (Y/n)" 147 | read -r choice < /dev/tty 148 | if [ "$choice" = "N" ] || [ "$choice" = "n" ]; then 149 | echo "spicetify Marketplace installation aborted" 150 | exit 0 151 | fi 152 | echo "Starting the spicetify Marketplace installation script.." 153 | curl -fsSL "https://raw.githubusercontent.com/spicetify/spicetify-marketplace/main/resources/install.sh" | sh 154 | -------------------------------------------------------------------------------- /jsHelper/homeConfig.js: -------------------------------------------------------------------------------- 1 | SpicetifyHomeConfig = {}; 2 | 3 | (async () => { 4 | // Status enum 5 | const NORMAL = 0; 6 | const STICKY = 1; 7 | const LOWERED = 2; 8 | // List of sections' metadata 9 | let list; 10 | // Store sections' statuses 11 | const statusDic = {}; 12 | let mounted = false; 13 | 14 | SpicetifyHomeConfig.arrange = (sections) => { 15 | mounted = true; 16 | if (list) { 17 | return list; 18 | } 19 | const stickList = (localStorage.getItem("spicetify-home-config:stick") || "").split(","); 20 | const lowList = (localStorage.getItem("spicetify-home-config:low") || "").split(","); 21 | const stickSections = []; 22 | const lowSections = []; 23 | for (const uri of stickList) { 24 | const index = sections.findIndex((a) => a?.uri === uri || a?.item?.uri === uri); 25 | if (index !== -1) { 26 | const item = sections[index]; 27 | const uri = item.item.uri || item.uri; 28 | statusDic[uri] = STICKY; 29 | stickSections.push(item); 30 | sections[index] = undefined; 31 | } 32 | } 33 | for (const uri of lowList) { 34 | const index = sections.findIndex((a) => a?.uri === uri || a?.item?.uri === uri); 35 | if (index !== -1) { 36 | const item = sections[index]; 37 | const uri = item.item.uri || item.uri; 38 | statusDic[uri] = LOWERED; 39 | lowSections.push(item); 40 | sections[index] = undefined; 41 | } 42 | } 43 | 44 | list = [...stickSections, ...sections.filter(Boolean), ...lowSections]; 45 | return list; 46 | }; 47 | 48 | const up = document.createElement("button"); 49 | up.innerText = "Up"; 50 | const down = document.createElement("button"); 51 | down.innerText = "Down"; 52 | const lower = document.createElement("button"); 53 | const stick = document.createElement("button"); 54 | const sectionStyle = document.createElement("style"); 55 | sectionStyle.innerHTML = ` 56 | .main-home-content section { 57 | order: 0 !important; 58 | } 59 | `; 60 | const containerStyle = document.createElement("style"); 61 | containerStyle.innerHTML = ` 62 | #spicetify-home-config { 63 | position: relative; 64 | width: 100%; 65 | height: 0; 66 | display: flex; 67 | justify-content: center; 68 | align-items: flex-start; 69 | gap: 5px; 70 | z-index: 9999; 71 | } 72 | #spicetify-home-config button { 73 | min-width: 60px; 74 | height: 40px; 75 | border-radius: 3px; 76 | background-color: var(--spice-main); 77 | color: var(--spice-text); 78 | border: 1px solid var(--spice-text); 79 | } 80 | #spicetify-home-config button:disabled { 81 | color: var(--spice-button-disabled); 82 | } 83 | `; 84 | 85 | const container = document.createElement("div"); 86 | container.id = "spicetify-home-config"; 87 | container.append(containerStyle, up, down, lower, stick); 88 | document.head.append(sectionStyle); 89 | let elem = []; 90 | 91 | function injectInteraction() { 92 | const main = document.querySelector(".main-home-content"); 93 | elem = [...main.querySelectorAll("section")]; 94 | for (const [index, item] of elem.entries()) { 95 | item.dataset.uri = list[index]?.uri ?? list[index].item?.uri; 96 | } 97 | 98 | function appendItems() { 99 | const stick = []; 100 | const low = []; 101 | const normal = []; 102 | for (const el of elem) { 103 | if (statusDic[el.dataset.uri] === STICKY) stick.push(el); 104 | else if (statusDic[el.dataset.uri] === LOWERED) low.push(el); 105 | else normal.push(el); 106 | } 107 | 108 | localStorage.setItem( 109 | "spicetify-home-config:stick", 110 | stick.map((a) => a.dataset.uri) 111 | ); 112 | localStorage.setItem( 113 | "spicetify-home-config:low", 114 | low.map((a) => a.dataset.uri) 115 | ); 116 | 117 | elem = [...stick, ...normal, ...low]; 118 | main.append(...elem); 119 | } 120 | 121 | function onSwap(item, dir) { 122 | container.remove(); 123 | const curPos = elem.findIndex((e) => e === item); 124 | const newPos = curPos + dir; 125 | if (newPos < 0 || newPos > elem.length - 1) return; 126 | 127 | [elem[curPos], elem[newPos]] = [elem[newPos], elem[curPos]]; 128 | [list[curPos], list[newPos]] = [list[newPos], list[curPos]]; 129 | appendItems(); 130 | } 131 | 132 | function onChangeStatus(item, status) { 133 | container.remove(); 134 | const isToggle = statusDic[item.dataset.uri] === status; 135 | statusDic[item.dataset.uri] = isToggle ? NORMAL : status; 136 | appendItems(); 137 | } 138 | 139 | for (const el of elem) { 140 | el.onmouseover = () => { 141 | const status = statusDic[el.dataset.uri]; 142 | const index = elem.findIndex((a) => a === el); 143 | 144 | if (!status || index === 0 || status !== statusDic[elem[index - 1]?.dataset.uri]) { 145 | up.disabled = true; 146 | } else { 147 | up.disabled = false; 148 | up.onclick = () => onSwap(el, -1); 149 | } 150 | 151 | if (!status || index === elem.length - 1 || status !== statusDic[elem[index + 1]?.dataset.uri]) { 152 | down.disabled = true; 153 | } else { 154 | down.disabled = false; 155 | down.onclick = () => onSwap(el, 1); 156 | } 157 | 158 | stick.innerText = status === STICKY ? "Unstick" : "Stick"; 159 | lower.innerText = status === LOWERED ? "Unlower" : "Lower"; 160 | lower.onclick = () => onChangeStatus(el, LOWERED); 161 | stick.onclick = () => onChangeStatus(el, STICKY); 162 | 163 | el.prepend(container); 164 | }; 165 | } 166 | } 167 | 168 | function removeInteraction() { 169 | container.remove(); 170 | for (const a of elem) { 171 | a.onmouseover = undefined; 172 | } 173 | } 174 | 175 | await new Promise((res) => Spicetify.Events.webpackLoaded.on(res)); 176 | 177 | SpicetifyHomeConfig.menu = new Spicetify.Menu.Item( 178 | "Home config", 179 | false, 180 | (self) => { 181 | self.setState(!self.isEnabled); 182 | if (self.isEnabled) { 183 | injectInteraction(); 184 | } else { 185 | removeInteraction(); 186 | } 187 | }, 188 | Spicetify.SVGIcons["grid-view"] 189 | ); 190 | 191 | SpicetifyHomeConfig.addToMenu = () => { 192 | SpicetifyHomeConfig.menu.register(); 193 | }; 194 | SpicetifyHomeConfig.removeMenu = () => { 195 | SpicetifyHomeConfig.menu.setState(false); 196 | SpicetifyHomeConfig.menu.deregister(); 197 | }; 198 | 199 | await new Promise((res) => Spicetify.Events.platformLoaded.on(res)); 200 | // Init 201 | if (Spicetify.Platform.History.location.pathname === "/") { 202 | SpicetifyHomeConfig.addToMenu(); 203 | } 204 | 205 | Spicetify.Platform.History.listen(({ pathname }) => { 206 | if (pathname === "/") { 207 | SpicetifyHomeConfig.addToMenu(); 208 | } else { 209 | SpicetifyHomeConfig.removeMenu(); 210 | } 211 | }); 212 | })(); 213 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Auto Skip Videos", 4 | "description": "Videos are unable to play in some regions because of Spotify's policy. Instead of jumping to next song in playlist, it just stops playing. And it's kinda annoying to open up the client to manually click next every times it happens. Use this extension to skip them automatically.", 5 | "preview": null, 6 | "main": "Extensions/autoSkipVideo.js" 7 | }, 8 | { 9 | "name": "Bookmark", 10 | "description": "Easily store and browse pages, play tracks or tracks in specific time. Useful for who wants to check out an artist, album later without following them or writing their name down.", 11 | "preview": "https://i.imgur.com/isgU4TS.png", 12 | "main": "Extensions/bookmark.js" 13 | }, 14 | { 15 | "name": "Christian Spotify", 16 | "description": "Auto skip explicit tracks. Toggle option is in Profile menu (top right button).", 17 | "preview": "https://i.imgur.com/5reGrBb.png", 18 | "main": "Extensions/autoSkipExplicit.js" 19 | }, 20 | { 21 | "name": "Keyboard Shortcut", 22 | "description": "Register some useful keybinds to support keyboard-driven navigation in Spotify client. Less time touching the mouse.", 23 | "preview": "https://i.imgur.com/evkGv9q.png", 24 | "main": "Extensions/keyboardShortcut.js" 25 | }, 26 | { 27 | "name": "Loopy Loop", 28 | "description": "Provide ability to mark start and end points on progress bar and automatically loop over that track portion.", 29 | "preview": "https://i.imgur.com/YEkbjLC.png", 30 | "main": "Extensions/loopyLoop.js" 31 | }, 32 | { 33 | "name": "Shuffle+", 34 | "description": "Shuffles using Fisher–Yates algorithm with zero bias. After installing extensions, right click album/playlist/artist item, there will be an option \"Play with Shuffle+\". You can also multiple select tracks and choose to \"Play with Shuffle+\". Moreover, enable option \"Auto Shuffle+\" in Profile menu to inject Shuffle+ into every play buttons, no need to right click anymore.", 35 | "preview": "https://i.imgur.com/gxbnqSN.png", 36 | "main": "Extensions/shuffle+.js" 37 | }, 38 | { 39 | "name": "Trash Bin", 40 | "description": "Throw songs/artists to trash bin and never hear them again (automatically skip). This extension will append a Throw to Trashbin option in tracks and artists link right click menu.", 41 | "preview": "https://i.imgur.com/ZFTy5Rm.png", 42 | "main": "Extensions/trashbin.js" 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /src/backup/backup.go: -------------------------------------------------------------------------------- 1 | package backup 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/spicetify/cli/src/utils" 8 | ) 9 | 10 | // Start backing up Spotify Apps folder to backupPath 11 | func Start(appPath, backupPath string) error { 12 | return utils.Copy(appPath, backupPath, false, []string{".spa"}) 13 | } 14 | 15 | // Extract all SPA files from backupPath to extractPath 16 | func Extract(backupPath, extractPath string) { 17 | for _, app := range []string{"xpui", "login"} { 18 | appPath := filepath.Join(backupPath, app+".spa") 19 | appExtractToFolder := filepath.Join(extractPath, app) 20 | 21 | _, err := os.Stat(appPath) 22 | if err != nil { 23 | continue 24 | } 25 | 26 | err = utils.Unzip(appPath, appExtractToFolder) 27 | if err != nil { 28 | utils.Fatal(err) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/cmd/auto.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | backupstatus "github.com/spicetify/cli/src/status/backup" 7 | spotifystatus "github.com/spicetify/cli/src/status/spotify" 8 | ) 9 | 10 | // Auto checks Spotify state, re-backup and apply if needed, then launch 11 | // Spotify client normally. 12 | func Auto(spicetifyVersion string) { 13 | backupVersion := backupSection.Key("version").MustString("") 14 | spotStat := spotifystatus.Get(appPath) 15 | backStat := backupstatus.Get(prefsPath, backupFolder, backupVersion) 16 | 17 | if spotStat.IsBackupable() && (backStat.IsEmpty() || backStat.IsOutdated()) { 18 | Backup(spicetifyVersion) 19 | backupVersion := backupSection.Key("version").MustString("") 20 | backStat = backupstatus.Get(prefsPath, backupFolder, backupVersion) 21 | } 22 | 23 | if !backStat.IsBackuped() { 24 | os.Exit(1) 25 | } 26 | 27 | if isAppX { 28 | spotStat = spotifystatus.Get(appDestPath) 29 | } 30 | 31 | if !spotStat.IsApplied() && backStat.IsBackuped() { 32 | CheckStates() 33 | InitSetting() 34 | Apply(spicetifyVersion) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/cmd/backup.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | spotifystatus "github.com/spicetify/cli/src/status/spotify" 8 | 9 | "github.com/spicetify/cli/src/backup" 10 | "github.com/spicetify/cli/src/preprocess" 11 | backupstatus "github.com/spicetify/cli/src/status/backup" 12 | "github.com/spicetify/cli/src/utils" 13 | ) 14 | 15 | // Backup stores original apps packages, extracts them and preprocesses 16 | // extracted apps' assets 17 | func Backup(spicetifyVersion string) { 18 | if isAppX { 19 | utils.PrintInfo(`You are using Spotify Windows Store version, which is only partly supported 20 | Stop using Spicetify with Windows Store version unless you absolutely CANNOT install normal Spotify from installer 21 | Modded Spotify cannot be launched using original Shortcut/Start menu tile. To correctly launch Spotify with modification, please make a desktop shortcut that execute "spicetify auto". After that, you can change its icon, pin to start menu or put in startup folder`) 22 | if !ReadAnswer("Continue backing up anyway? [y/N]: ", false, true) { 23 | os.Exit(1) 24 | } 25 | } 26 | backupVersion := backupSection.Key("version").MustString("") 27 | backStat := backupstatus.Get(prefsPath, backupFolder, backupVersion) 28 | if !backStat.IsEmpty() { 29 | utils.PrintInfo("There is available backup") 30 | utils.PrintInfo("Clear current backup:") 31 | 32 | spotStat := spotifystatus.Get(appPath) 33 | if spotStat.IsBackupable() { 34 | clearBackup() 35 | 36 | } else { 37 | utils.PrintWarning(`After clearing backup, Spotify cannot be backed up again`) 38 | utils.PrintInfo(`Please restore first then backup, run "spicetify restore backup" or re-install Spotify then run "spicetify backup"`) 39 | os.Exit(1) 40 | } 41 | } 42 | 43 | utils.PrintBold("Backing up app files:") 44 | 45 | if err := backup.Start(appPath, backupFolder); err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | appList, err := os.ReadDir(backupFolder) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | 54 | totalApp := len(appList) 55 | if totalApp > 0 { 56 | utils.PrintGreen("OK") 57 | } else { 58 | utils.PrintError("Cannot backup app files. Reinstall Spotify and try again") 59 | os.Exit(1) 60 | } 61 | 62 | utils.PrintBold("Extracting:") 63 | backup.Extract(backupFolder, rawFolder) 64 | utils.PrintGreen("OK") 65 | 66 | utils.PrintBold("Preprocessing:") 67 | 68 | spotifyBasePath := spotifyPath 69 | if spotifyBasePath == "" { 70 | utils.PrintError("Spotify installation path not found. Cannot preprocess V8 snapshots") 71 | } else { 72 | preprocess.Start( 73 | spicetifyVersion, 74 | spotifyBasePath, 75 | rawFolder, 76 | preprocess.Flag{ 77 | DisableSentry: preprocSection.Key("disable_sentry").MustBool(false), 78 | DisableLogging: preprocSection.Key("disable_ui_logging").MustBool(false), 79 | RemoveRTL: preprocSection.Key("remove_rtl_rule").MustBool(false), 80 | ExposeAPIs: preprocSection.Key("expose_apis").MustBool(false), 81 | SpotifyVer: utils.GetSpotifyVersion(prefsPath)}, 82 | ) 83 | } 84 | utils.PrintSuccess("Preprocessing completed") 85 | 86 | err = utils.Copy(rawFolder, themedFolder, true, []string{".html", ".js", ".css"}) 87 | if err != nil { 88 | utils.Fatal(err) 89 | } 90 | 91 | preprocess.StartCSS(themedFolder) 92 | utils.PrintSuccess("CSS replacing completed") 93 | 94 | backupSection.Key("version").SetValue(utils.GetSpotifyVersion(prefsPath)) 95 | backupSection.Key("with").SetValue(spicetifyVersion) 96 | cfg.Write() 97 | utils.PrintSuccess("Everything is ready, you can start applying now!") 98 | } 99 | 100 | // Clear clears current backup. Before clearing, it checks whether Spotify is in 101 | // valid state to backup again. 102 | func Clear() { 103 | spotStat := spotifystatus.Get(appPath) 104 | 105 | if !spotStat.IsBackupable() { 106 | utils.PrintWarning("Before clearing backup, please restore or re-install Spotify to stock state") 107 | os.Exit(1) 108 | } 109 | 110 | clearBackup() 111 | } 112 | 113 | func clearBackup() { 114 | if err := os.RemoveAll(backupFolder); err != nil { 115 | utils.Fatal(err) 116 | } 117 | os.Mkdir(backupFolder, 0700) 118 | 119 | if err := os.RemoveAll(rawFolder); err != nil { 120 | utils.Fatal(err) 121 | } 122 | os.Mkdir(rawFolder, 0700) 123 | 124 | if err := os.RemoveAll(themedFolder); err != nil { 125 | utils.Fatal(err) 126 | } 127 | os.Mkdir(themedFolder, 0700) 128 | 129 | backupSection.Key("version").SetValue("") 130 | backupSection.Key("with").SetValue("") 131 | cfg.Write() 132 | utils.PrintSuccess("Backup is cleared.") 133 | } 134 | 135 | // Restore uses backup to revert every changes made by Spicetify. 136 | func Restore() { 137 | CheckStates() 138 | 139 | if err := os.RemoveAll(appDestPath); err != nil { 140 | utils.Fatal(err) 141 | } 142 | 143 | if err := utils.Copy(backupFolder, appDestPath, false, []string{".spa"}); err != nil { 144 | utils.Fatal(err) 145 | } 146 | 147 | utils.PrintSuccess("Spotify is restored") 148 | } 149 | -------------------------------------------------------------------------------- /src/cmd/block-updates.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | 11 | "github.com/spicetify/cli/src/utils" 12 | ) 13 | 14 | // Block spotify updates. Taken from https://github.com/Delusoire/bespoke-cli/blob/main/cmd/spotify/update.go 15 | func BlockSpotifyUpdates(disabled bool) { 16 | if runtime.GOOS == "linux" { 17 | utils.PrintError("Auto-updates on linux should be disabled in package manager you installed spotify with.") 18 | return 19 | } 20 | spotifyExecPath := GetSpotifyPath() 21 | switch runtime.GOOS { 22 | case "windows": 23 | spotifyExecPath = filepath.Join(spotifyExecPath, "Spotify.exe") 24 | case "darwin": 25 | spotifyExecPath = filepath.Join(spotifyExecPath, "..", "MacOS", "Spotify") 26 | } 27 | 28 | var str, msg string 29 | if runtime.GOOS == "darwin" { 30 | homeDir, err := os.UserHomeDir() 31 | if err != nil { 32 | utils.PrintError("Cannot get user home directory") 33 | return 34 | } 35 | updateDir := homeDir + "/Library/Application Support/Spotify/PersistentCache/Update" 36 | if disabled { 37 | exec.Command("pkill", "Spotify").Run() 38 | exec.Command("mkdir", "-p", updateDir).Run() 39 | exec.Command("chflags", "uchg", updateDir).Run() 40 | msg = "Disabled" 41 | } else { 42 | exec.Command("pkill", "Spotify").Run() 43 | exec.Command("mkdir", "-p", updateDir).Run() 44 | exec.Command("chflags", "nouchg", updateDir).Run() 45 | msg = "Enabled" 46 | } 47 | 48 | utils.PrintSuccess(msg + " Spotify updates!") 49 | return 50 | } 51 | 52 | file, err := os.OpenFile(spotifyExecPath, os.O_RDWR, 0644) 53 | if err != nil { 54 | utils.Fatal(err) 55 | return 56 | } 57 | defer file.Close() 58 | 59 | buf := new(bytes.Buffer) 60 | buf.ReadFrom(file) 61 | content := buf.String() 62 | 63 | i := strings.Index(content, "desktop-update/") 64 | if i == -1 { 65 | utils.PrintError("Can't find update endpoint in executable") 66 | return 67 | } 68 | if disabled { 69 | str = "no/thanks" 70 | msg = "Disabled" 71 | } else { 72 | str = "v2/update" 73 | msg = "Enabled" 74 | } 75 | file.WriteAt([]byte(str), int64(i+15)) 76 | utils.PrintSuccess(msg + " Spotify updates!") 77 | } 78 | -------------------------------------------------------------------------------- /src/cmd/color.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/go-ini/ini" 9 | "github.com/spicetify/cli/src/utils" 10 | ) 11 | 12 | const ( 13 | nameMaxLen = 42 14 | ) 15 | 16 | // EditColor changes one or multiple colors' values 17 | func EditColor(args []string) { 18 | if !initCmdColor() { 19 | return 20 | } 21 | 22 | for len(args) >= 2 { 23 | field := args[0] 24 | value := args[1] 25 | args = args[2:] 26 | 27 | color := utils.ParseColor(value).Hex() 28 | 29 | if key, err := colorSection.GetKey(field); err == nil { 30 | key.SetValue(color) 31 | colorChangeSuccess(field, color) 32 | continue 33 | } 34 | 35 | if len(utils.BaseColorList[field]) > 0 { 36 | colorSection.NewKey(field, color) 37 | colorChangeSuccess(field, color) 38 | continue 39 | } 40 | 41 | utils.PrintWarning(`Color "` + field + `" unchanged: Not found.`) 42 | } 43 | 44 | colorCfg.SaveTo(filepath.Join(themeFolder, "color.ini")) 45 | } 46 | 47 | // DisplayColors prints out every color name, hex and rgb value. 48 | func DisplayColors() { 49 | colorFileOk := initCmdColor() 50 | 51 | if !colorFileOk { 52 | return 53 | } 54 | 55 | for _, k := range utils.BaseColorOrder { 56 | colorString := "" 57 | if colorFileOk { 58 | colorString = colorSection.Key(k).String() 59 | } 60 | 61 | if len(colorString) == 0 { 62 | colorString = utils.BaseColorList[k] 63 | k += " (*)" 64 | } 65 | 66 | out := formatName(k) + formatColor(colorString) 67 | log.Println(out) 68 | } 69 | 70 | for _, v := range colorSection.Keys() { 71 | key := v.Name() 72 | 73 | if len(utils.BaseColorList[key]) != 0 { 74 | continue 75 | } 76 | 77 | out := formatName(key) + formatColor(v.String()) 78 | log.Println(out) 79 | } 80 | 81 | log.Println("\n(*): Default color is used") 82 | } 83 | 84 | func initCmdColor() bool { 85 | var err error 86 | 87 | themeName := settingSection.Key("current_theme").String() 88 | 89 | if len(themeName) == 0 { 90 | utils.PrintError(`Config "current_theme" is blank.`) 91 | return false 92 | } 93 | 94 | themeFolder = getThemeFolder(themeName) 95 | 96 | colorPath := filepath.Join(themeFolder, "color.ini") 97 | 98 | colorCfg, err = ini.InsensitiveLoad(colorPath) 99 | if err != nil { 100 | utils.PrintError("Cannot open file " + colorPath) 101 | return false 102 | } 103 | 104 | sections := colorCfg.Sections() 105 | 106 | if len(sections) < 2 { 107 | utils.PrintError("No section found in " + colorPath) 108 | return false 109 | } 110 | 111 | schemeName := settingSection.Key("color_scheme").String() 112 | if len(schemeName) == 0 { 113 | colorSection = sections[1] 114 | } else { 115 | schemeSection, err := colorCfg.GetSection(schemeName) 116 | if err != nil { 117 | colorSection = sections[1] 118 | } else { 119 | colorSection = schemeSection 120 | } 121 | } 122 | 123 | return true 124 | } 125 | 126 | func colorChangeSuccess(field, value string) { 127 | utils.PrintSuccess(`Color changed: ` + field + ` = ` + value) 128 | utils.PrintInfo(`Run "spicetify update" to apply new color`) 129 | } 130 | 131 | func formatColor(value string) string { 132 | color := utils.ParseColor(value) 133 | return "\x1B[48;2;" + color.TerminalRGB() + "m \033[0m | " + color.Hex() + " | " + color.RGB() 134 | } 135 | 136 | func formatName(name string) string { 137 | nameLen := len(name) 138 | if nameLen > nameMaxLen { 139 | name = name[:(nameMaxLen - 3)] 140 | name += "..." 141 | nameLen = nameMaxLen 142 | } 143 | return utils.Bold(name) + strings.Repeat(" ", nameMaxLen-nameLen) 144 | } 145 | -------------------------------------------------------------------------------- /src/cmd/config-dir.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spicetify/cli/src/utils" 5 | ) 6 | 7 | // ShowConfigDirectory shows config directory in user's default file manager application 8 | func ShowConfigDirectory() { 9 | configDir := utils.GetSpicetifyFolder() 10 | err := utils.ShowDirectory(configDir) 11 | if err != nil { 12 | utils.PrintError("Error opening config directory:") 13 | utils.Fatal(err) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/go-ini/ini" 10 | "github.com/spicetify/cli/src/utils" 11 | ) 12 | 13 | // EditConfig changes one or multiple config value 14 | func EditConfig(args []string) { 15 | for len(args) >= 2 { 16 | field := args[0] 17 | value := args[1] 18 | 19 | switch field { 20 | case "extensions", "custom_apps": 21 | arrayType(featureSection, field, value) 22 | case "spotify_launch_flags": 23 | continue 24 | case "prefs_path", "spotify_path", "current_theme", "color_scheme": 25 | stringType(settingSection, field, value) 26 | 27 | default: 28 | toggleType(field, value) 29 | } 30 | 31 | args = args[2:] 32 | } 33 | 34 | cfg.Write() 35 | } 36 | 37 | // DisplayAllConfig displays all configs in all sections 38 | func DisplayAllConfig() { 39 | maxLen := 30 40 | utils.PrintBold("Settings") 41 | for _, key := range settingSection.Keys() { 42 | name := key.Name() 43 | log.Println(name + strings.Repeat(" ", maxLen-len(name)) + key.Value()) 44 | } 45 | 46 | log.Println() 47 | utils.PrintBold("Preprocesses") 48 | for _, key := range preprocSection.Keys() { 49 | name := key.Name() 50 | log.Println(name + strings.Repeat(" ", maxLen-len(name)) + key.Value()) 51 | } 52 | 53 | log.Println() 54 | utils.PrintBold("AdditionalFeatures") 55 | for _, key := range featureSection.Keys() { 56 | name := key.Name() 57 | if name == "extensions" || name == "custom_apps" || name == "spotify_launch_flags" { 58 | list := key.Strings("|") 59 | listLen := len(list) 60 | if listLen == 0 { 61 | log.Println(name) 62 | } else { 63 | log.Println(name + strings.Repeat(" ", maxLen-len(name)) + strings.Join(list, " | ")) 64 | } 65 | } else { 66 | log.Println(name + strings.Repeat(" ", maxLen-len(name)) + key.Value()) 67 | } 68 | } 69 | 70 | log.Println() 71 | utils.PrintBold("Backup") 72 | for _, key := range backupSection.Keys() { 73 | name := key.Name() 74 | log.Println(name + strings.Repeat(" ", maxLen-len(name)) + key.Value()) 75 | } 76 | } 77 | 78 | // DisplayConfig displays value of requested config field 79 | func DisplayConfig(field string) { 80 | key := searchField(field) 81 | 82 | name := key.Name() 83 | if name == "extensions" || name == "custom_apps" { 84 | list := key.Strings("|") 85 | for _, ext := range list { 86 | log.Println(ext) 87 | } 88 | return 89 | } 90 | 91 | log.Println(key.Value()) 92 | } 93 | 94 | // searchField finds requested field in all three config sections 95 | func searchField(field string) *ini.Key { 96 | key, err := settingSection.GetKey(field) 97 | if err != nil { 98 | key, err = preprocSection.GetKey(field) 99 | if err != nil { 100 | key, err = featureSection.GetKey(field) 101 | if err != nil { 102 | unchangeWarning(field, `Not a valid field.`) 103 | os.Exit(1) 104 | } 105 | } 106 | } 107 | return key 108 | } 109 | 110 | func changeSuccess(key, value string) { 111 | utils.PrintSuccess(`Config changed: ` + key + ` = ` + value) 112 | utils.PrintInfo(`Run "spicetify apply" to apply new config`) 113 | } 114 | 115 | func unchangeWarning(field, reason string) { 116 | utils.PrintWarning(`Config "` + field + `" unchanged: ` + reason) 117 | } 118 | 119 | func arrayType(section *ini.Section, field, value string) { 120 | key, err := section.GetKey(field) 121 | if err != nil { 122 | utils.Fatal(err) 123 | } 124 | 125 | if strings.TrimSpace(value) == "" { 126 | key.SetValue("") 127 | changeSuccess(field, "") 128 | return 129 | } 130 | 131 | allExts := make(map[string]bool) 132 | for _, v := range key.Strings("|") { 133 | allExts[v] = true 134 | } 135 | 136 | values := strings.Split(value, "|") 137 | duplicates := []string{} 138 | inputValues := make(map[string]bool) 139 | modifiedValues := 0 140 | 141 | for _, value := range values { 142 | isSubstract := strings.HasSuffix(value, "-") 143 | if isSubstract { 144 | value = value[:len(value)-1] 145 | } 146 | 147 | if isSubstract { 148 | if _, found := allExts[value]; !found { 149 | unchangeWarning(field, fmt.Sprintf("%s is not on the list.", value)) 150 | return 151 | } 152 | 153 | modifiedValues++ 154 | delete(allExts, value) 155 | } else { 156 | if _, found := allExts[value]; found && !inputValues[value] { 157 | duplicates = append(duplicates, value) 158 | } else if _, found := allExts[value]; !found { 159 | allExts[value] = true 160 | modifiedValues++ 161 | } 162 | 163 | inputValues[value] = true 164 | } 165 | } 166 | 167 | if len(duplicates) > 0 { 168 | unchangeWarning(field, fmt.Sprintf("%s %s already in the list.", strings.Join(duplicates, ", "), pluralize(len(duplicates), "is", "are"))) 169 | } 170 | 171 | if modifiedValues == 0 { 172 | return 173 | } 174 | 175 | newList := make([]string, 0, len(allExts)) 176 | for k := range allExts { 177 | newList = append(newList, k) 178 | } 179 | 180 | key.SetValue(strings.Join(newList, "|")) 181 | changeSuccess(field, strings.Join(newList, "|")) 182 | } 183 | 184 | func pluralize(count int, singular, plural string) string { 185 | if count == 1 { 186 | return singular 187 | } 188 | return plural 189 | } 190 | 191 | func stringType(section *ini.Section, field, value string) { 192 | key, err := section.GetKey(field) 193 | if err != nil { 194 | utils.Fatal(err) 195 | } 196 | if len(strings.TrimSpace(value)) == 0 || value[len(value)-1] == '-' { 197 | value = "" 198 | } 199 | key.SetValue(value) 200 | 201 | changeSuccess(field, value) 202 | } 203 | 204 | func toggleType(field, value string) { 205 | key := searchField(field) 206 | 207 | if value != "0" && value != "1" && value != "-1" { 208 | unchangeWarning(field, `"`+value+`" is not valid value. Only "0", "1" or "-1".`) 209 | return 210 | } 211 | 212 | key.SetValue(value) 213 | changeSuccess(field, value) 214 | } 215 | -------------------------------------------------------------------------------- /src/cmd/devtools.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | 11 | "github.com/spicetify/cli/src/utils" 12 | ) 13 | 14 | // SetDevTool enables/disables developer mode of Spotify client 15 | func SetDevTools() { 16 | var filePath string 17 | 18 | switch runtime.GOOS { 19 | case "windows": 20 | appFilePath := os.Getenv("LOCALAPPDATA") + "\\Spotify\\offline.bnk" 21 | if _, err := os.Stat(appFilePath); err == nil { 22 | filePath = appFilePath 23 | } else if len(utils.WinXApp()) != 0 && len(utils.WinXPrefs()) != 0 { 24 | dir, _ := filepath.Split(utils.WinXPrefs()) 25 | filePath = filepath.Join(dir, "offline.bnk") 26 | } 27 | case "linux": 28 | { 29 | homePath := os.Getenv("HOME") 30 | snapSpotifyHome := homePath + "/snap/spotify/common" 31 | if _, err := os.Stat(snapSpotifyHome); err == nil { 32 | homePath = snapSpotifyHome 33 | } 34 | 35 | flatpakHome := homePath + "/.var/app/com.spotify.Client" 36 | if _, err := os.Stat(flatpakHome); err == nil { 37 | homePath = flatpakHome 38 | filePath = homePath + "/cache/spotify/offline.bnk" 39 | } else { 40 | filePath = homePath + "/.cache/spotify/offline.bnk" 41 | } 42 | 43 | } 44 | case "darwin": 45 | filePath = os.Getenv("HOME") + "/Library/Application Support/Spotify/PersistentCache/offline.bnk" 46 | } 47 | 48 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 49 | utils.PrintError("Can't find \"offline.bnk\". Try running spotify first.") 50 | os.Exit(1) 51 | } 52 | 53 | file, err := os.OpenFile(filePath, os.O_RDWR, 0644) 54 | 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | defer file.Close() 59 | 60 | buf := new(bytes.Buffer) 61 | buf.ReadFrom(file) 62 | content := buf.String() 63 | firstLocation := strings.Index(content, "app-developer") 64 | firstPatchLocation := int64(firstLocation + 14) 65 | 66 | secondLocation := strings.LastIndex(content, "app-developer") 67 | secondPatchLocation := int64(secondLocation + 15) 68 | 69 | file.WriteAt([]byte{50}, firstPatchLocation) 70 | file.WriteAt([]byte{50}, secondPatchLocation) 71 | utils.PrintSuccess("Enabled DevTools!") 72 | } 73 | -------------------------------------------------------------------------------- /src/cmd/patch.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/spicetify/cli/src/utils" 10 | ) 11 | 12 | func Patch() { 13 | keys := patchSection.Keys() 14 | 15 | re := regexp.MustCompile(`^([\w\d\-~\.]+)_find_(\d+)$`) 16 | for _, key := range keys { 17 | keyName := key.Name() 18 | matches := re.FindStringSubmatch(keyName) 19 | if len(matches) == 0 { 20 | continue 21 | } 22 | 23 | name := matches[1] 24 | assetPath := filepath.Join(appPath, "xpui", name) 25 | index := matches[2] 26 | 27 | if _, err := os.Stat(assetPath); err != nil { 28 | utils.PrintError("File name \"" + name + "\" is not found.") 29 | continue 30 | } 31 | 32 | replName := name + "_repl_all_" + index 33 | replOnceName := name + "_repl_" + index 34 | replKey, errAll := patchSection.GetKey(replName) 35 | replOnceKey, errOnce := patchSection.GetKey(replOnceName) 36 | 37 | if errAll != nil && errOnce != nil { 38 | utils.PrintError("Cannot find replace string for patch \"" + keyName + "\"") 39 | utils.PrintInfo("Correct key name for replace string are") 40 | utils.PrintInfo(" \"" + replOnceName + "\"") 41 | utils.PrintInfo(" \"" + replName + "\"") 42 | continue 43 | } 44 | 45 | patchRegexp, errReg := regexp.Compile(key.String()) 46 | if errReg != nil { 47 | utils.PrintError("Cannot compile find RegExp for patch \"" + keyName + "\"") 48 | continue 49 | } 50 | 51 | utils.ModifyFile(assetPath, func(content string) string { 52 | if errAll == nil { // Prioritize replace all 53 | return patchRegexp.ReplaceAllString(content, replKey.MustString("")) 54 | } else { 55 | match := patchRegexp.FindString(content) 56 | if len(match) > 0 { 57 | toReplace := patchRegexp.ReplaceAllString(match, replOnceKey.MustString("")) 58 | content = strings.Replace(content, match, toReplace, 1) 59 | } 60 | return content 61 | } 62 | }) 63 | 64 | utils.PrintSuccess("\"" + keyName + "\" is patched") 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/cmd/path.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/spicetify/cli/src/utils" 9 | ) 10 | 11 | // ThemeAssetPath returns path of theme; assets, color.ini, theme.js and user.css 12 | func ThemeAssetPath(kind string) (string, error) { 13 | InitSetting() 14 | 15 | if kind == "root" { 16 | return filepath.Join(utils.GetExecutableDir(), "Themes"), nil 17 | } else if len(themeFolder) == 0 { 18 | return "", errors.New(`config "current_theme" is blank`) 19 | } 20 | 21 | if kind == "folder" { 22 | return themeFolder, nil 23 | } else if kind == "color" { 24 | color := filepath.Join(themeFolder, "color.ini") 25 | return color, nil 26 | } else if kind == "css" { 27 | css := filepath.Join(themeFolder, "user.css") 28 | return css, nil 29 | } else if kind == "js" { 30 | js := filepath.Join(themeFolder, "theme.js") 31 | return js, nil 32 | } else if kind == "assets" { 33 | assets := filepath.Join(themeFolder, "assets") 34 | return assets, nil 35 | } 36 | 37 | return "", errors.New(`unrecognized theme assets kind. only "root", "folder", "color", "css", "js" or "assets" is valid`) 38 | } 39 | 40 | // ThemeAllAssetsPath returns paths of all theme's assets 41 | func ThemeAllAssetsPath() (string, error) { 42 | InitSetting() 43 | 44 | if len(themeFolder) == 0 { 45 | return "", errors.New(`config "current_theme" is blank`) 46 | } 47 | 48 | results := []string{ 49 | themeFolder, 50 | filepath.Join(themeFolder, "color.ini"), 51 | filepath.Join(themeFolder, "user.css"), 52 | filepath.Join(themeFolder, "theme.js"), 53 | filepath.Join(themeFolder, "assets")} 54 | 55 | return strings.Join(results, "\n"), nil 56 | } 57 | 58 | // ExtensionPath return path of extension file 59 | func ExtensionPath(name string) (string, error) { 60 | if name == "root" { 61 | return filepath.Join(utils.GetExecutableDir(), "Extensions"), nil 62 | } 63 | return utils.GetExtensionPath(name) 64 | } 65 | 66 | // ExtensionAllPath returns paths of all extension files 67 | func ExtensionAllPath() (string, error) { 68 | exts := featureSection.Key("extensions").Strings("|") 69 | results := []string{} 70 | for _, v := range exts { 71 | path, err := utils.GetExtensionPath(v) 72 | if err != nil { 73 | path = utils.Red("Extension " + v + " not found") 74 | } 75 | results = append(results, path) 76 | } 77 | 78 | return strings.Join(results, "\n"), nil 79 | } 80 | 81 | // AppPath return path of app directory 82 | func AppPath(name string) (string, error) { 83 | if name == "root" { 84 | return filepath.Join(utils.GetExecutableDir(), "CustomApps"), nil 85 | } 86 | return utils.GetCustomAppPath(name) 87 | } 88 | 89 | // AppAllPath returns paths of all apps 90 | func AppAllPath() (string, error) { 91 | exts := featureSection.Key("custom_apps").Strings("|") 92 | results := []string{} 93 | for _, v := range exts { 94 | path, err := utils.GetCustomAppPath(v) 95 | if err != nil { 96 | path = utils.Red("App " + v + " not found") 97 | } 98 | results = append(results, path) 99 | } 100 | 101 | return strings.Join(results, "\n"), nil 102 | } 103 | 104 | func AllPaths() (string, error) { 105 | theme, _ := ThemeAllAssetsPath() 106 | ext, _ := ExtensionAllPath() 107 | app, _ := AppAllPath() 108 | 109 | return strings.Join([]string{theme, ext, app}, "\n"), nil 110 | } 111 | -------------------------------------------------------------------------------- /src/cmd/restart.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "runtime" 9 | ) 10 | 11 | // EvalSpotifyRestart Restarts/starts spotify 12 | func EvalSpotifyRestart(start bool, flags ...string) { 13 | launchFlag := settingSection.Key("spotify_launch_flags").Strings("|") 14 | if len(launchFlag) > 0 { 15 | flags = append(flags, launchFlag...) 16 | } 17 | 18 | enableDevtools := settingSection.Key("always_enable_devtools").MustBool(false) 19 | if enableDevtools { 20 | SetDevTools() 21 | } 22 | 23 | switch runtime.GOOS { 24 | case "windows": 25 | isRunning := exec.Command("tasklist", "/FI", "ImageName eq spotify.exe") 26 | result, _ := isRunning.Output() 27 | if !bytes.Contains(result, []byte("No tasks are running")) || start { 28 | exec.Command("taskkill", "/F", "/IM", "spotify.exe").Run() 29 | if isAppX { 30 | ps, _ := exec.LookPath("powershell.exe") 31 | exe := filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "WindowsApps", "Spotify.exe") 32 | flags = append([]string{"-NoProfile", "-NonInteractive", `& "` + exe + `" --app-directory="` + appDestPath + `"`}, flags...) 33 | exec.Command(ps, flags...).Start() 34 | } else { 35 | exec.Command(filepath.Join(spotifyPath, "spotify.exe"), flags...).Start() 36 | } 37 | } 38 | case "linux": 39 | isRunning := exec.Command("pgrep", "spotify") 40 | _, err := isRunning.Output() 41 | if err == nil || start { 42 | exec.Command("pkill", "spotify").Run() 43 | exec.Command(filepath.Join(spotifyPath, "spotify"), flags...).Start() 44 | } 45 | case "darwin": 46 | isRunning := exec.Command("sh", "-c", "ps aux | grep 'Spotify' | grep -v grep") 47 | _, err := isRunning.CombinedOutput() 48 | if err == nil || start { 49 | exec.Command("pkill", "Spotify").Run() 50 | flags = append([]string{"-a", "/Applications/Spotify.app", "--args"}, flags...) 51 | exec.Command("open", flags...).Start() 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/cmd/update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "runtime" 10 | 11 | "github.com/spicetify/cli/src/utils" 12 | ) 13 | 14 | func Update(currentVersion string) bool { 15 | utils.PrintBold("Fetch latest release info:") 16 | tagName, err := utils.FetchLatestTag() 17 | if err != nil { 18 | utils.PrintError("Cannot fetch latest release info") 19 | utils.PrintError(err.Error()) 20 | return false 21 | } 22 | utils.PrintGreen("OK") 23 | 24 | utils.PrintInfo("Current version: " + currentVersion) 25 | utils.PrintInfo("Latest release: " + tagName) 26 | if currentVersion == tagName { 27 | utils.PrintSuccess("Already up-to-date.") 28 | return false 29 | } 30 | 31 | var assetURL string = "https://github.com/spicetify/cli/releases/download/v" + tagName + "/spicetify-" + tagName 32 | var location string 33 | switch runtime.GOOS { 34 | case "windows": 35 | if runtime.GOARCH == "386" { 36 | assetURL += "-windows-x32.zip" 37 | } else if runtime.GOARCH == "arm64" { 38 | assetURL += "-windows-arm64.zip" 39 | } else { 40 | assetURL += "-windows-x64.zip" 41 | } 42 | location = os.TempDir() + "/spicetify-" + tagName + ".zip" 43 | case "linux": 44 | if runtime.GOARCH == "arm64" { 45 | assetURL += "-linux-arm64.tar.gz" 46 | } else { 47 | assetURL += "-linux-amd64.tar.gz" 48 | } 49 | location = os.TempDir() + "/spicetify-" + tagName + ".tar.gz" 50 | case "darwin": 51 | if runtime.GOARCH == "arm64" { 52 | assetURL += "-darwin-arm64.tar.gz" 53 | } else { 54 | assetURL += "-darwin-amd64.tar.gz" 55 | } 56 | location = os.TempDir() + "/spicetify-" + tagName + ".tar.gz" 57 | } 58 | 59 | utils.PrintBold("Downloading:") 60 | 61 | out, err := os.Create(location) 62 | if err != nil { 63 | utils.Fatal(err) 64 | } 65 | defer out.Close() 66 | 67 | resp2, err := http.Get(assetURL) 68 | if err != nil { 69 | utils.Fatal(err) 70 | } 71 | 72 | _, err = io.Copy(out, resp2.Body) 73 | if err != nil { 74 | utils.Fatal(err) 75 | } 76 | utils.PrintGreen("OK") 77 | 78 | exe, err := os.Executable() 79 | if err != nil { 80 | utils.Fatal(err) 81 | } 82 | if exe, err = filepath.EvalSymlinks(exe); err != nil { 83 | utils.Fatal(err) 84 | } 85 | 86 | exeOld := exe + ".old" 87 | utils.CheckExistAndDelete(exeOld) 88 | 89 | if err = os.Rename(exe, exeOld); err != nil { 90 | permissionError(err) 91 | } 92 | 93 | utils.PrintBold("Extracting:") 94 | switch runtime.GOOS { 95 | case "windows": 96 | err = utils.Unzip(location, utils.GetExecutableDir()) 97 | 98 | case "linux", "darwin": 99 | err = exec.Command("tar", "-xzf", location, "-C", utils.GetExecutableDir()).Run() 100 | } 101 | if err != nil { 102 | os.Rename(exeOld, exe) 103 | permissionError(err) 104 | } 105 | 106 | utils.CheckExistAndDelete(exeOld) 107 | utils.PrintGreen("OK") 108 | utils.PrintSuccess("spicetify is up-to-date.") 109 | return true 110 | } 111 | 112 | func permissionError(err error) { 113 | utils.PrintInfo("If fatal error is \"Permission denied\", please check read/write permission of spicetify executable directory.") 114 | utils.PrintInfo("However, if you used a package manager to install spicetify, please upgrade by using the same package manager.") 115 | utils.Fatal(err) 116 | } 117 | -------------------------------------------------------------------------------- /src/cmd/watch.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | spotifystatus "github.com/spicetify/cli/src/status/spotify" 10 | "github.com/spicetify/cli/src/utils" 11 | ) 12 | 13 | var ( 14 | debuggerURL string 15 | autoReloadFunc func() 16 | ) 17 | 18 | // Watch . 19 | func Watch(liveUpdate bool) { 20 | if !isValidForWatching() { 21 | os.Exit(1) 22 | } 23 | 24 | InitSetting() 25 | 26 | if liveUpdate { 27 | startDebugger() 28 | } 29 | 30 | if len(themeFolder) == 0 { 31 | utils.PrintError(`Config "current_theme" is blank. No theme asset to watch`) 32 | os.Exit(1) 33 | } 34 | 35 | colorPath := filepath.Join(themeFolder, "color.ini") 36 | cssPath := filepath.Join(themeFolder, "user.css") 37 | 38 | fileList := []string{} 39 | if replaceColors { 40 | fileList = append(fileList, colorPath) 41 | } 42 | 43 | if injectCSS { 44 | fileList = append(fileList, cssPath) 45 | } 46 | 47 | if injectJS { 48 | jsPath := filepath.Join(themeFolder, "theme.js") 49 | pathArr := []string{jsPath} 50 | 51 | if _, err := os.Stat(jsPath); err == nil { 52 | go utils.Watch(pathArr, func(_ string, err error) { 53 | if err != nil { 54 | utils.Fatal(err) 55 | } 56 | 57 | refreshThemeJS() 58 | utils.PrintSuccess(utils.PrependTime("Theme's JS was reloaded")) 59 | }, autoReloadFunc) 60 | } 61 | } 62 | 63 | if overwriteAssets { 64 | assetPath := filepath.Join(themeFolder, "assets") 65 | 66 | if _, err := os.Stat(assetPath); err == nil { 67 | go utils.WatchRecursive(assetPath, func(_ string, err error) { 68 | if err != nil { 69 | utils.Fatal(err) 70 | } 71 | 72 | refreshThemeAssets() 73 | utils.PrintSuccess(utils.PrependTime("Custom assets were reloaded")) 74 | }, autoReloadFunc) 75 | } 76 | } 77 | 78 | utils.Watch(fileList, func(_ string, err error) { 79 | if err != nil { 80 | utils.Fatal(err) 81 | } 82 | 83 | InitSetting() 84 | refreshThemeCSS() 85 | utils.PrintSuccess(utils.PrependTime("Custom CSS is updated")) 86 | }, autoReloadFunc) 87 | } 88 | 89 | // WatchExtensions . 90 | func WatchExtensions(extName []string, liveUpdate bool) { 91 | if !isValidForWatching() { 92 | os.Exit(1) 93 | } 94 | 95 | if liveUpdate { 96 | startDebugger() 97 | } 98 | 99 | var extNameList []string 100 | if len(extName) > 0 { 101 | extNameList = extName 102 | } else { 103 | extNameList = featureSection.Key("extensions").Strings("|") 104 | } 105 | 106 | var extPathList []string 107 | 108 | for _, v := range extNameList { 109 | extPath, err := utils.GetExtensionPath(v) 110 | if err != nil { 111 | utils.PrintError(`Extension "` + v + `" not found.`) 112 | continue 113 | } 114 | extPathList = append(extPathList, extPath) 115 | } 116 | 117 | if len(extPathList) == 0 { 118 | utils.PrintError("No extension to watch") 119 | os.Exit(1) 120 | } 121 | 122 | utils.Watch(extPathList, func(filePath string, err error) { 123 | if err != nil { 124 | utils.PrintError(err.Error()) 125 | os.Exit(1) 126 | } 127 | 128 | pushExtensions("", filePath) 129 | 130 | utils.PrintSuccess(utils.PrependTime(`Extension "` + filePath + `" is updated`)) 131 | }, autoReloadFunc) 132 | } 133 | 134 | // WatchCustomApp . 135 | func WatchCustomApp(appName []string, liveUpdate bool) { 136 | if !isValidForWatching() { 137 | os.Exit(1) 138 | } 139 | 140 | if liveUpdate { 141 | startDebugger() 142 | } 143 | 144 | var appNameList []string 145 | if len(appName) > 0 { 146 | appNameList = appName 147 | } else { 148 | appNameList = featureSection.Key("custom_apps").Strings("|") 149 | } 150 | 151 | threadCount := 0 152 | for _, v := range appNameList { 153 | appPath, err := utils.GetCustomAppPath(v) 154 | if err != nil { 155 | utils.PrintError(`Custom app "` + v + `" not found`) 156 | continue 157 | } 158 | 159 | var appFileList []string 160 | jsFilePath := filepath.Join(appPath, "index.js") 161 | if _, err := os.Stat(jsFilePath); err != nil { 162 | utils.PrintError(`Custom app "` + v + `" does not contain index.js`) 163 | continue 164 | } 165 | appFileList = append(appFileList, jsFilePath) 166 | cssFilePath := filepath.Join(appPath, "style.css") 167 | if _, err := os.Stat(cssFilePath); err == nil { 168 | appFileList = append(appFileList, cssFilePath) 169 | } 170 | 171 | manifestPath := filepath.Join(appPath, "manifest.json") 172 | manifestFileContent, err := os.ReadFile(manifestPath) 173 | if err == nil { 174 | var manifestJson utils.AppManifest 175 | if err = json.Unmarshal(manifestFileContent, &manifestJson); err == nil { 176 | for _, subfile := range manifestJson.Files { 177 | subfilePath := filepath.Join(appPath, subfile) 178 | appFileList = append(appFileList, subfilePath) 179 | } 180 | for _, subfile := range manifestJson.ExtensionFiles { 181 | subfilePath := filepath.Join(appPath, subfile) 182 | appFileList = append(appFileList, subfilePath) 183 | } 184 | } 185 | 186 | } 187 | 188 | threadCount += 1 189 | var appName = v 190 | go utils.Watch(appFileList, func(filePath string, err error) { 191 | if err != nil { 192 | utils.PrintError(err.Error()) 193 | os.Exit(1) 194 | } 195 | 196 | RefreshApps(appName) 197 | 198 | utils.PrintSuccess(utils.PrependTime(`Custom app "` + appName + `" is updated`)) 199 | }, autoReloadFunc) 200 | } 201 | 202 | if threadCount > 0 { 203 | for { 204 | time.Sleep(utils.INTERVAL) 205 | } 206 | } 207 | } 208 | 209 | func isValidForWatching() bool { 210 | status := spotifystatus.Get(appDestPath) 211 | 212 | if !status.IsModdable() { 213 | utils.PrintError(`You haven't applied. Run "spicetify apply" once before entering watch mode`) 214 | return false 215 | } 216 | 217 | return true 218 | } 219 | 220 | func startDebugger() { 221 | if len(utils.GetDebuggerPath()) == 0 { 222 | SetDevTools() 223 | EvalSpotifyRestart(true, "--remote-debugging-port=9222", "--remote-allow-origins=*") 224 | utils.PrintInfo("Spotify is restarted with debugger on. Waiting...") 225 | for len(utils.GetDebuggerPath()) == 0 { 226 | // Wait until debugger is up 227 | } 228 | } 229 | autoReloadFunc = func() { 230 | if utils.SendReload(&debuggerURL) != nil { 231 | utils.PrintError("Could not Reload Spotify") 232 | utils.PrintInfo(`Close Spotify and run watch command again`) 233 | } else { 234 | utils.PrintSuccess("Spotify reloaded") 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/status/backup/backup.go: -------------------------------------------------------------------------------- 1 | package backupstatus 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spicetify/cli/src/utils" 9 | ) 10 | 11 | type status struct { 12 | state int 13 | } 14 | 15 | // Status . 16 | type Status interface { 17 | IsBackuped() bool 18 | IsEmpty() bool 19 | IsOutdated() bool 20 | } 21 | 22 | const ( 23 | // EMPTY No backup found 24 | EMPTY int = iota 25 | // BACKUPED There is available backup 26 | BACKUPED 27 | // OUTDATED Available backup has different version from Spotify version 28 | OUTDATED 29 | ) 30 | 31 | // Get returns status of backup folder 32 | func Get(prefsPath, backupPath, backupVersion string) Status { 33 | fileList, err := os.ReadDir(backupPath) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | 38 | cur := EMPTY 39 | 40 | if len(fileList) != 0 { 41 | spaCount := 0 42 | for _, file := range fileList { 43 | if !file.IsDir() && strings.HasSuffix(file.Name(), ".spa") { 44 | spaCount++ 45 | } 46 | } 47 | 48 | if spaCount > 0 { 49 | spotifyVersion := utils.GetSpotifyVersion(prefsPath) 50 | 51 | if backupVersion != spotifyVersion { 52 | cur = OUTDATED 53 | } else { 54 | cur = BACKUPED 55 | } 56 | } 57 | } 58 | 59 | return status{ 60 | state: cur} 61 | } 62 | 63 | func (s status) IsBackuped() bool { 64 | return s.state == BACKUPED 65 | } 66 | 67 | func (s status) IsEmpty() bool { 68 | return s.state == EMPTY 69 | } 70 | 71 | func (s status) IsOutdated() bool { 72 | return s.state == OUTDATED 73 | } 74 | -------------------------------------------------------------------------------- /src/status/spotify/spotify.go: -------------------------------------------------------------------------------- 1 | package spotifystatus 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | type status struct { 10 | state int 11 | } 12 | 13 | // Status . 14 | type Status interface { 15 | IsBackupable() bool 16 | IsModdable() bool 17 | IsStock() bool 18 | IsMixed() bool 19 | IsApplied() bool 20 | IsInvalid() bool 21 | } 22 | 23 | const ( 24 | // STOCK Spotify is in original state 25 | STOCK int = iota 26 | // INVALID Apps folder is empty 27 | INVALID 28 | // APPLIED Spotify is modified 29 | APPLIED 30 | // MIXED Spotify has modified files and stock files 31 | MIXED 32 | ) 33 | 34 | // Get returns status of Spotify's Apps folder 35 | func Get(appsFolder string) Status { 36 | fileList, err := os.ReadDir(appsFolder) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | 41 | spaCount := 0 42 | dirCount := 0 43 | for _, file := range fileList { 44 | if file.IsDir() { 45 | dirCount++ 46 | } else if strings.HasSuffix(file.Name(), ".spa") { 47 | spaCount++ 48 | } 49 | } 50 | 51 | cur := INVALID 52 | if spaCount > 0 && dirCount > 0 { 53 | cur = MIXED 54 | } else if spaCount > 0 { 55 | cur = STOCK 56 | } else if dirCount > 0 { 57 | cur = APPLIED 58 | } 59 | 60 | return status{ 61 | state: cur} 62 | } 63 | 64 | func (s status) IsBackupable() bool { 65 | return s.state == STOCK || s.state == MIXED 66 | } 67 | 68 | func (s status) IsModdable() bool { 69 | return s.state == APPLIED || s.state == MIXED 70 | } 71 | 72 | func (s status) IsStock() bool { 73 | return s.state == STOCK 74 | } 75 | 76 | func (s status) IsMixed() bool { 77 | return s.state == MIXED 78 | } 79 | 80 | func (s status) IsApplied() bool { 81 | return s.state == APPLIED 82 | } 83 | 84 | func (s status) IsInvalid() bool { 85 | return s.state == INVALID 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/color.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | var ( 15 | xrdb map[string]string 16 | 17 | // BaseColorList is color names list and their default values 18 | BaseColorList = map[string]string{ 19 | "text": "ffffff", 20 | "subtext": "b3b3b3", 21 | "main": "121212", 22 | "main-elevated": "242424", 23 | "highlight": "1a1a1a", 24 | "highlight-elevated": "2a2a2a", 25 | "sidebar": "000000", 26 | "player": "181818", 27 | "card": "282828", 28 | "shadow": "000000", 29 | "selected-row": "ffffff", 30 | "button": "1db954", 31 | "button-active": "1ed760", 32 | "button-disabled": "535353", 33 | "tab-active": "333333", 34 | "notification": "4687d6", 35 | "notification-error": "e22134", 36 | "misc": "7f7f7f", 37 | } 38 | 39 | // BaseColorOrder is color name list in an order 40 | BaseColorOrder = []string{ 41 | "text", 42 | "subtext", 43 | "main", 44 | "main-elevated", 45 | "highlight", 46 | "highlight-elevated", 47 | "sidebar", 48 | "player", 49 | "card", 50 | "shadow", 51 | "selected-row", 52 | "button", 53 | "button-active", 54 | "button-disabled", 55 | "tab-active", 56 | "notification", 57 | "notification-error", 58 | "misc", 59 | } 60 | ) 61 | 62 | type color struct { 63 | red, green, blue int64 64 | } 65 | 66 | // Color stores hex and rgb value of color 67 | type Color interface { 68 | Hex() string 69 | RGB() string 70 | TerminalRGB() string 71 | } 72 | 73 | // ParseColor parses a string in both hex or rgb 74 | // or from XResources or env variable 75 | // and converts to both rgb and hex value 76 | func ParseColor(raw string) Color { 77 | var red, green, blue int64 78 | 79 | if strings.HasPrefix(raw, "${") { 80 | endIndex := len(raw) - 1 81 | raw = raw[2:endIndex] 82 | 83 | // From XResources database 84 | if strings.HasPrefix(raw, "xrdb:") { 85 | raw = fromXResources(raw) 86 | 87 | // From environment variable 88 | } else if env := os.Getenv(raw); len(env) > 0 { 89 | raw = env 90 | } 91 | } 92 | 93 | // rrr,bbb,ggg 94 | if strings.Contains(raw, ",") { 95 | list := strings.SplitN(raw, ",", 3) 96 | list = append(list, "255", "255") 97 | 98 | red = stringToInt(list[0], 10) 99 | green = stringToInt(list[1], 10) 100 | blue = stringToInt(list[2], 10) 101 | 102 | } else { 103 | re := regexp.MustCompile("[a-fA-F0-9]+") 104 | hex := re.FindString(raw) 105 | 106 | // Support short hex color form e.g. #fff, #121 107 | if len(hex) == 3 { 108 | expanded := []byte{ 109 | hex[0], hex[0], 110 | hex[1], hex[1], 111 | hex[2], hex[2]} 112 | 113 | hex = string(expanded) 114 | } 115 | 116 | hex += "ffffff" 117 | 118 | red = stringToInt(hex[:2], 16) 119 | green = stringToInt(hex[2:4], 16) 120 | blue = stringToInt(hex[4:6], 16) 121 | } 122 | 123 | return color{red, green, blue} 124 | } 125 | 126 | func (c color) Hex() string { 127 | return fmt.Sprintf("%02x%02x%02x", c.red, c.green, c.blue) 128 | } 129 | 130 | func (c color) RGB() string { 131 | return fmt.Sprintf("%d,%d,%d", c.red, c.green, c.blue) 132 | } 133 | 134 | func (c color) TerminalRGB() string { 135 | return fmt.Sprintf("%d;%d;%d", c.red, c.green, c.blue) 136 | } 137 | 138 | func stringToInt(raw string, base int) int64 { 139 | value, err := strconv.ParseInt(raw, base, 0) 140 | if err != nil { 141 | value = 255 142 | } 143 | 144 | if value < 0 { 145 | value = 0 146 | } 147 | 148 | if value > 255 { 149 | value = 255 150 | } 151 | 152 | return value 153 | } 154 | 155 | func getXRDB() error { 156 | db := map[string]string{} 157 | 158 | if len(xrdb) > 0 { 159 | return nil 160 | } 161 | 162 | output, err := exec.Command("xrdb", "-query").Output() 163 | if err != nil { 164 | return err 165 | } 166 | 167 | scanner := bufio.NewScanner(bytes.NewReader(output)) 168 | re := regexp.MustCompile(`^\*\.?(\w+?):\s*?#([A-Za-z0-9]+)`) 169 | for scanner.Scan() { 170 | line := scanner.Text() 171 | for _, match := range re.FindAllStringSubmatch(line, -1) { 172 | if match != nil { 173 | db[match[1]] = match[2] 174 | } 175 | } 176 | } 177 | 178 | xrdb = db 179 | 180 | return nil 181 | } 182 | 183 | func fromXResources(input string) string { 184 | // Example input: 185 | // xrdb:color1 186 | // xrdb:color2:#f0c 187 | // xrdb:color5:40,50,60 188 | queries := strings.Split(input, ":") 189 | if len(queries[1]) == 0 { 190 | PrintError(`"` + input + `": Wrong XResources lookup syntax`) 191 | os.Exit(0) 192 | } 193 | 194 | if err := getXRDB(); err != nil { 195 | Fatal(err) 196 | } 197 | 198 | if len(xrdb) < 1 { 199 | PrintError("XResources is not available") 200 | os.Exit(0) 201 | } 202 | 203 | value, ok := xrdb[queries[1]] 204 | 205 | if !ok || len(value) == 0 { 206 | if len(queries) > 2 { 207 | // Fallback value 208 | value = queries[2] 209 | } else { 210 | PrintError("Variable is not available in XResources") 211 | os.Exit(0) 212 | } 213 | } 214 | 215 | return value 216 | } 217 | -------------------------------------------------------------------------------- /src/utils/file-utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "fmt" 7 | "os" 8 | "unicode/utf16" 9 | ) 10 | 11 | func ReadStringFromUTF16Binary(inputFile string, startMarker []byte, endMarker []byte) (string, int, int, error) { 12 | fileContent, err := os.ReadFile(inputFile) 13 | if err != nil { 14 | return "", -1, -1, fmt.Errorf("error reading file %s: %w", inputFile, err) 15 | } 16 | 17 | isUTF16LE := false 18 | if len(fileContent) >= 2 && fileContent[0] == 0xFF && fileContent[1] == 0xFE { 19 | isUTF16LE = true 20 | } 21 | 22 | if !isUTF16LE && len(fileContent) > 100 && fileContent[1] == 0x00 { 23 | isUTF16LE = true 24 | } 25 | 26 | var startIdx, endIdx int 27 | var contentToSearch []byte 28 | var searchStartMarker, searchEndMarker []byte 29 | 30 | if !isUTF16LE { 31 | return "", -1, -1, fmt.Errorf("file is not in UTF-16LE format: %s", inputFile) 32 | } 33 | 34 | contentToSearch = fileContent[2:] 35 | searchStartMarker = encodeUTF16LE(startMarker) 36 | searchEndMarker = encodeUTF16LE(endMarker) 37 | 38 | startIdx = bytes.Index(contentToSearch, searchStartMarker) 39 | if startIdx == -1 { 40 | return "", -1, -1, fmt.Errorf("start marker not found: %s", string(startMarker)) 41 | } 42 | 43 | searchSpace := contentToSearch[startIdx+len(searchStartMarker):] 44 | endIdx = bytes.Index(searchSpace, searchEndMarker) 45 | if endIdx == -1 { 46 | return "", -1, -1, fmt.Errorf("end marker not found after start index %d: %s", startIdx+len(searchStartMarker), string(endMarker)) 47 | } 48 | 49 | stringContentBytes := contentToSearch[startIdx : startIdx+len(searchStartMarker)+endIdx+len(searchEndMarker)] 50 | 51 | decodedStringBytes, err := decodeUTF16LE(stringContentBytes) 52 | if err != nil { 53 | return "", -1, -1, fmt.Errorf("error decoding UTF-16LE content: %w", err) 54 | } 55 | 56 | // Adjust indices to be byte offsets in the original file 57 | originalStartIdx := 2 + startIdx 58 | originalEndIdx := 2 + endIdx + len(stringContentBytes) 59 | return string(decodedStringBytes), originalStartIdx, originalEndIdx, nil 60 | } 61 | 62 | // Helper function to encode a byte slice (assumed UTF-8) to UTF-16LE 63 | func encodeUTF16LE(data []byte) []byte { 64 | utf16Bytes := utf16.Encode([]rune(string(data))) 65 | byteSlice := make([]byte, len(utf16Bytes)*2) 66 | for i, r := range utf16Bytes { 67 | binary.LittleEndian.PutUint16(byteSlice[i*2:], r) 68 | } 69 | 70 | return byteSlice 71 | } 72 | 73 | // Helper function to decode a byte slice (UTF-16LE) to UTF-8 74 | func decodeUTF16LE(data []byte) ([]byte, error) { 75 | if len(data)%2 != 0 { 76 | return nil, fmt.Errorf("invalid UTF-16LE data length") 77 | } 78 | 79 | uint16s := make([]uint16, len(data)/2) 80 | for i := 0; i < len(data)/2; i++ { 81 | uint16s[i] = binary.LittleEndian.Uint16(data[i*2:]) 82 | } 83 | 84 | runes := utf16.Decode(uint16s) 85 | return []byte(string(runes)), nil 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/isAdmin/unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package isAdmin 5 | 6 | import "os" 7 | 8 | func Check(bypassAdminCheck bool) bool { 9 | if bypassAdminCheck { 10 | return false 11 | } 12 | return os.Geteuid() == 0 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/isAdmin/windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package isAdmin 5 | 6 | import ( 7 | "golang.org/x/sys/windows" 8 | ) 9 | 10 | func Check(bypassAdminCheck bool) bool { 11 | if bypassAdminCheck { 12 | return false 13 | } 14 | 15 | var sid *windows.SID 16 | err := windows.AllocateAndInitializeSid( 17 | &windows.SECURITY_NT_AUTHORITY, 18 | 2, 19 | windows.SECURITY_BUILTIN_DOMAIN_RID, 20 | windows.DOMAIN_ALIAS_RID_ADMINS, 21 | 0, 0, 0, 0, 0, 0, 22 | &sid) 23 | if err != nil { 24 | return false 25 | } 26 | defer windows.FreeSid(sid) 27 | 28 | token := windows.Token(0) 29 | member, err := token.IsMember(sid) 30 | return err == nil && member 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/path-utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | ) 10 | 11 | func MigrateConfigFolder() { 12 | if runtime.GOOS == "windows" { 13 | source := filepath.Join(os.Getenv("USERPROFILE"), ".spicetify") 14 | if _, err := os.Stat(source); err == nil { 15 | PrintBold("Migrating spicetify config folder") 16 | destination := GetSpicetifyFolder() 17 | err := Copy(source, destination, true, nil) 18 | if err != nil { 19 | Fatal(err) 20 | } 21 | os.RemoveAll(source) 22 | PrintGreen("OK") 23 | } 24 | } 25 | } 26 | 27 | func MigrateFolders() { 28 | backupPath := filepath.Join(GetSpicetifyFolder(), "Backup") 29 | extractedPath := filepath.Join(GetSpicetifyFolder(), "Extracted") 30 | 31 | if _, err := os.Stat(backupPath); err == nil { 32 | newBackupPath := GetStateFolder("Backup") 33 | oldAbs, err := filepath.Abs(backupPath) 34 | if err != nil { 35 | Fatal(err) 36 | } 37 | newAbs, err := filepath.Abs(newBackupPath) 38 | if err != nil { 39 | Fatal(err) 40 | } 41 | 42 | if oldAbs != newAbs { 43 | PrintBold("Migrating spicetify state (Backup, Extracted) folders") 44 | err := Copy(backupPath, newBackupPath, true, nil) 45 | if err != nil { 46 | Fatal(err) 47 | } 48 | os.RemoveAll(backupPath) 49 | } 50 | } 51 | 52 | if _, err := os.Stat(extractedPath); err == nil { 53 | newExtractedPath := GetStateFolder("Extracted") 54 | oldAbs, err := filepath.Abs(extractedPath) 55 | if err != nil { 56 | Fatal(err) 57 | } 58 | newAbs, err := filepath.Abs(newExtractedPath) 59 | if err != nil { 60 | Fatal(err) 61 | } 62 | if oldAbs != newAbs { 63 | PrintBold("Migrating spicetify state (Backup, Extracted) folders") 64 | err := Copy(extractedPath, newExtractedPath, true, nil) 65 | if err != nil { 66 | Fatal(err) 67 | } 68 | os.RemoveAll(extractedPath) 69 | } 70 | } 71 | } 72 | 73 | func ReplaceEnvVarsInString(input string) string { 74 | var replacements []string 75 | for _, v := range os.Environ() { 76 | pair := strings.SplitN(v, "=", 2) 77 | replacements = append(replacements, "$"+pair[0], pair[1]) 78 | } 79 | replacer := strings.NewReplacer(replacements...) 80 | return replacer.Replace(input) 81 | } 82 | 83 | func GetSpicetifyFolder() string { 84 | result, isAvailable := os.LookupEnv("SPICETIFY_CONFIG") 85 | defer func() { CheckExistAndCreate(result) }() 86 | 87 | if isAvailable && len(result) > 0 { 88 | return result 89 | } 90 | 91 | if runtime.GOOS == "windows" { 92 | parent := os.Getenv("APPDATA") 93 | 94 | result = filepath.Join(parent, "spicetify") 95 | } else if runtime.GOOS == "linux" { 96 | parent, isAvailable := os.LookupEnv("XDG_CONFIG_HOME") 97 | 98 | if !isAvailable || len(parent) == 0 { 99 | parent = filepath.Join(os.Getenv("HOME"), ".config") 100 | CheckExistAndCreate(parent) 101 | } 102 | 103 | result = filepath.Join(parent, "spicetify") 104 | } else if runtime.GOOS == "darwin" { 105 | parent := filepath.Join(os.Getenv("HOME"), ".config") 106 | CheckExistAndCreate(parent) 107 | 108 | result = filepath.Join(parent, "spicetify") 109 | } 110 | 111 | return result 112 | } 113 | 114 | func GetStateFolder(name string) string { 115 | result, isAvailable := os.LookupEnv("SPICETIFY_STATE") 116 | defer func() { CheckExistAndCreate(result) }() 117 | 118 | if isAvailable && len(result) > 0 { 119 | return result 120 | } 121 | 122 | if runtime.GOOS == "windows" { 123 | parent := os.Getenv("APPDATA") 124 | 125 | result = filepath.Join(parent, "spicetify") 126 | } else if runtime.GOOS == "linux" { 127 | parent, isAvailable := os.LookupEnv("XDG_STATE_HOME") 128 | 129 | if !isAvailable || len(parent) == 0 { 130 | parent = filepath.Join(os.Getenv("HOME"), ".local", "state") 131 | CheckExistAndCreate(parent) 132 | } 133 | 134 | result = filepath.Join(parent, "spicetify") 135 | } else if runtime.GOOS == "darwin" { 136 | parent := filepath.Join(os.Getenv("HOME"), ".local", "state") 137 | CheckExistAndCreate(parent) 138 | 139 | result = filepath.Join(parent, "spicetify") 140 | } 141 | 142 | return GetSubFolder(result, name) 143 | } 144 | 145 | // GetSubFolder checks if folder `name` is available in specified folder, 146 | // else creates then returns the path. 147 | func GetSubFolder(folder string, name string) string { 148 | dir := filepath.Join(folder, name) 149 | CheckExistAndCreate(dir) 150 | 151 | return dir 152 | } 153 | 154 | var userAppsFolder = GetSubFolder(GetSpicetifyFolder(), "CustomApps") 155 | var userExtensionsFolder = GetSubFolder(GetSpicetifyFolder(), "Extensions") 156 | 157 | func GetCustomAppSubfolderPath(folderPath string) string { 158 | entries, err := os.ReadDir(folderPath) 159 | if err != nil { 160 | return "" 161 | } 162 | 163 | for _, entry := range entries { 164 | if entry.IsDir() { 165 | subfolderPath := filepath.Join(folderPath, entry.Name()) 166 | indexPath := filepath.Join(subfolderPath, "index.js") 167 | 168 | if _, err := os.Stat(indexPath); err == nil { 169 | return subfolderPath 170 | } 171 | 172 | if subfolderPath := GetCustomAppSubfolderPath(subfolderPath); subfolderPath != "" { 173 | return subfolderPath 174 | } 175 | } 176 | } 177 | 178 | return "" 179 | } 180 | 181 | func GetCustomAppPath(name string) (string, error) { 182 | customAppFolderPath := filepath.Join(userAppsFolder, name) 183 | 184 | if _, err := os.Stat(customAppFolderPath); err == nil { 185 | customAppActualFolderPath := GetCustomAppSubfolderPath(customAppFolderPath) 186 | if customAppActualFolderPath != "" { 187 | return customAppActualFolderPath, nil 188 | } 189 | return customAppFolderPath, nil 190 | } 191 | 192 | customAppFolderPath = filepath.Join(GetExecutableDir(), "CustomApps", name) 193 | 194 | if _, err := os.Stat(customAppFolderPath); err == nil { 195 | customAppActualFolderPath := GetCustomAppSubfolderPath(customAppFolderPath) 196 | if customAppActualFolderPath != "" { 197 | return customAppActualFolderPath, nil 198 | } 199 | return customAppFolderPath, nil 200 | } 201 | 202 | return "", errors.New("custom app not found") 203 | } 204 | 205 | func GetExtensionPath(name string) (string, error) { 206 | extFilePath := filepath.Join(userExtensionsFolder, name) 207 | 208 | if _, err := os.Stat(extFilePath); err == nil { 209 | return extFilePath, nil 210 | } 211 | 212 | extFilePath = filepath.Join(GetExecutableDir(), "Extensions", name) 213 | 214 | if _, err := os.Stat(extFilePath); err == nil { 215 | return extFilePath, nil 216 | } 217 | 218 | return "", errors.New("extension not found") 219 | } 220 | -------------------------------------------------------------------------------- /src/utils/print.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | // Bold . 9 | func Bold(text string) string { 10 | return "\x1B[1m" + text + "\033[0m" 11 | } 12 | 13 | // Red . 14 | func Red(text string) string { 15 | return "\x1B[31m" + text + "\x1B[0m" 16 | } 17 | 18 | // Green . 19 | func Green(text string) string { 20 | return "\x1B[32m" + text + "\x1B[0m" 21 | } 22 | 23 | // Yellow . 24 | func Yellow(text string) string { 25 | return "\x1B[33m" + text + "\x1B[0m" 26 | } 27 | 28 | // Blue . 29 | func Blue(text string) string { 30 | return "\x1B[34m" + text + "\x1B[0m" 31 | } 32 | 33 | // PrintBold prints a bold message 34 | func PrintBold(text string) { 35 | log.Println(Bold(text)) 36 | } 37 | 38 | // PrintRed prints a message in red color 39 | func PrintRed(text string) { 40 | log.Println(Red(text)) 41 | } 42 | 43 | // PrintGreen prints a message in green color 44 | func PrintGreen(text string) { 45 | log.Println(Green(text)) 46 | } 47 | 48 | // PrintNote prints a warning message 49 | func PrintNote(text string) { 50 | log.Println(Bold(Yellow("note")), Bold(text)) 51 | } 52 | 53 | // PrintWarning prints a warning message 54 | func PrintWarning(text string) { 55 | log.Println(Yellow("warning"), text) 56 | } 57 | 58 | // PrintError prints an error message 59 | func PrintError(text string) { 60 | log.Println(Red("error"), text) 61 | } 62 | 63 | // PrintSuccess prints a success message 64 | func PrintSuccess(text string) { 65 | log.Println(Green("success"), text) 66 | } 67 | 68 | // PrintInfo prints an info message 69 | func PrintInfo(text string) { 70 | log.Println(Blue("info"), text) 71 | } 72 | 73 | // Fatal prints fatal message and exits process 74 | func Fatal(err error) { 75 | log.Println(Red("fatal"), err) 76 | os.Exit(1) 77 | } 78 | -------------------------------------------------------------------------------- /src/utils/scanner.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os/exec" 7 | ) 8 | 9 | // CmdScanner is a helper function to scan output from exec.Cmd 10 | func CmdScanner(cmd *exec.Cmd) { 11 | stdout, _ := cmd.StdoutPipe() 12 | cmd.Start() 13 | 14 | scanner := bufio.NewScanner(stdout) 15 | for scanner.Scan() { 16 | m := scanner.Text() 17 | fmt.Println(m) 18 | } 19 | cmd.Wait() 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/show-dir.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os/exec" 5 | "runtime" 6 | ) 7 | 8 | // ShowDirectory shows directory in user's default file manager application 9 | func ShowDirectory(dir string) error { 10 | 11 | var err error 12 | err = nil 13 | 14 | if runtime.GOOS == "windows" { 15 | _, err = exec.Command("explorer", dir).Output() 16 | if err != nil && err.Error() == "exit status 1" { 17 | err = nil 18 | } 19 | } else if runtime.GOOS == "linux" { 20 | _, err = exec.Command("xdg-open", dir).Output() 21 | } else if runtime.GOOS == "darwin" { 22 | _, err = exec.Command("open", dir).Output() 23 | } 24 | 25 | return err 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/ternary-bool.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // TernaryBool is three-way boolean: default, true, false 4 | type TernaryBool int 5 | 6 | // IsDefault . 7 | func (b TernaryBool) IsDefault() bool { 8 | return b == 0 9 | } 10 | 11 | // ToString . 12 | func (b TernaryBool) ToString() string { 13 | if b == 1 { 14 | return "true" 15 | } else if b == -1 { 16 | return "false" 17 | } 18 | 19 | return "" 20 | } 21 | 22 | // ToForceOperator . 23 | func (b TernaryBool) ToForceOperator() string { 24 | if b == 1 { 25 | return "true||" 26 | } else if b == -1 { 27 | return "false&&" 28 | } 29 | 30 | return "" 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/tracker.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | ) 8 | 9 | // Tracker is used to hold, update and print progress info to console. 10 | type Tracker struct { 11 | current int 12 | total int 13 | maxLen int 14 | } 15 | 16 | // NewTracker creates new tracker instance 17 | func NewTracker(total int) *Tracker { 18 | t := &Tracker{0, total, 0} 19 | return t 20 | } 21 | 22 | // Update increases progress count and prints current progress. 23 | func (t *Tracker) Update(name string) { 24 | t.current++ 25 | line := fmt.Sprintf("\r[ %d / %d ] %s", t.current, t.total, name) 26 | lineLen := len(line) 27 | spaceLen := 0 28 | 29 | if lineLen > t.maxLen { 30 | t.maxLen = lineLen 31 | } else { 32 | spaceLen = t.maxLen - lineLen 33 | } 34 | 35 | fmt.Print(line + strings.Repeat(" ", spaceLen)) 36 | } 37 | 38 | // Finish prints success message 39 | func (t *Tracker) Finish() { 40 | log.Println("\r\x1B[32mOK\033[0m" + strings.Repeat(" ", t.maxLen-2)) 41 | } 42 | 43 | // Reset . 44 | func (t *Tracker) Reset() { 45 | t.current = 0 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/vcs.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | type GithubRelease struct { 11 | TagName string `json:"tag_name"` 12 | Message string `json:"message"` 13 | } 14 | 15 | func FetchLatestTag() (string, error) { 16 | res, err := http.Get("https://api.github.com/repos/spicetify/cli/releases/latest") 17 | if err != nil { 18 | return "", err 19 | } 20 | 21 | body, err := io.ReadAll(res.Body) 22 | if err != nil { 23 | return "", err 24 | } 25 | 26 | var release GithubRelease 27 | if err = json.Unmarshal(body, &release); err != nil { 28 | return "", err 29 | } 30 | 31 | if release.TagName == "" { 32 | return "", errors.New("GitHub response: " + release.Message) 33 | } 34 | 35 | return release.TagName[1:], nil 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/watcher.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | 13 | "golang.org/x/net/websocket" 14 | ) 15 | 16 | var ( 17 | // INTERVAL . 18 | INTERVAL = 200 * time.Millisecond 19 | ) 20 | 21 | // Watch . 22 | func Watch(fileList []string, callbackEach func(fileName string, err error), callbackAfter func()) { 23 | var cache = map[string][]byte{} 24 | 25 | for { 26 | finalCallback := false 27 | for _, v := range fileList { 28 | curr, err := os.ReadFile(v) 29 | if err != nil { 30 | callbackEach(v, err) 31 | continue 32 | } 33 | 34 | if !bytes.Equal(cache[v], curr) { 35 | callbackEach(v, nil) 36 | cache[v] = curr 37 | finalCallback = true 38 | } 39 | } 40 | 41 | if callbackAfter != nil && finalCallback { 42 | callbackAfter() 43 | } 44 | 45 | time.Sleep(INTERVAL) 46 | } 47 | } 48 | 49 | // WatchRecursive . 50 | func WatchRecursive(root string, callbackEach func(fileName string, err error), callbackAfter func()) { 51 | var cache = map[string][]byte{} 52 | 53 | for { 54 | finalCallback := false 55 | 56 | filepath.WalkDir(root, func(filePath string, info os.DirEntry, _ error) error { 57 | if info.IsDir() { 58 | return nil 59 | } 60 | 61 | curr, err := os.ReadFile(filePath) 62 | if err != nil { 63 | callbackEach(filePath, err) 64 | return nil 65 | } 66 | 67 | if !bytes.Equal(cache[filePath], curr) { 68 | callbackEach(filePath, nil) 69 | cache[filePath] = curr 70 | finalCallback = true 71 | } 72 | 73 | return nil 74 | }) 75 | 76 | if callbackAfter != nil && finalCallback { 77 | callbackAfter() 78 | } 79 | 80 | time.Sleep(INTERVAL) 81 | } 82 | } 83 | 84 | type debugger struct { 85 | Description string 86 | DevtoolsFrontendUrl string 87 | Id string 88 | Title string 89 | Type string 90 | Url string 91 | WebSocketDebuggerUrl string 92 | } 93 | 94 | // GetDebuggerPath fetches opening debugger list from localhost and returns 95 | // the Spotify one. 96 | func GetDebuggerPath() string { 97 | res, err := http.Get("http://localhost:9222/json/list") 98 | if err != nil { 99 | return "" 100 | } 101 | 102 | body, err := io.ReadAll(res.Body) 103 | if err != nil { 104 | return "" 105 | } 106 | 107 | var list []debugger 108 | if err = json.Unmarshal(body, &list); err != nil { 109 | return "" 110 | } 111 | 112 | for _, debugger := range list { 113 | if strings.Contains(debugger.Url, "spotify") { 114 | return debugger.WebSocketDebuggerUrl 115 | } 116 | } 117 | 118 | return "" 119 | } 120 | 121 | // SendReload sends reload command to debugger Websocket server 122 | func SendReload(debuggerURL *string) error { 123 | if len(*debuggerURL) == 0 { 124 | *debuggerURL = GetDebuggerPath() 125 | } 126 | 127 | socket, err := websocket.Dial(*debuggerURL, "", "http://localhost/") 128 | if err != nil { 129 | return nil 130 | } 131 | defer socket.Close() 132 | 133 | if _, err := socket.Write([]byte(`{"id":0,"method":"Runtime.evaluate","params":{"expression":"window.location.reload()"}}`)); err != nil { 134 | return nil 135 | } 136 | 137 | return nil 138 | } 139 | --------------------------------------------------------------------------------