Repository: spicetify/cli Branch: main Commit: 919a0197562e Files: 100 Total size: 930.5 KB Directory structure: gitextract_d9xm56qv/ ├── .coderabbit.yaml ├── .github/ │ ├── 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 │ │ ├── index.js │ │ ├── manifest.json │ │ └── 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 ├── utils.go ├── vcs.go └── watcher.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coderabbit.yaml ================================================ issue_enrichment: auto_enrich: enabled: false ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" ================================================ FILE: .github/labeler.yml ================================================ 📦 aur: - "(aur)" 📦 snap: - "(snap)" 📦 brew: - "(brew)" 🪟 windows: - "(windows)" 🐧 linux: - "(linux)" 🍎 macos: - "(macos)" 🔵 extension: - "(extension|auto.*?skip|bookmark|full.*?app.*?display|keyboard.*?shortcut|shuffle|web.*?now.*?playing|popup.*?lyrics)" 🔴 custom app: - "(custom.*?app|lyrics.*?plus|new.*?releases|store|reddit|lyrics)" 🖇 duplicate: - "(duplicate)" ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: pull_request: branches: - "main" - "*/main/*/**" push: branches: - "main" - "*/main/*/**" release: types: [published] jobs: build: name: Build runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 with: go-version-file: "go.mod" - name: Build run: go build . - name: Format run: | gofmt -s -l . if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi release: permissions: id-token: write contents: write attestations: write name: Release strategy: matrix: os: ["linux", "darwin", "windows"] arch: ["amd64", "arm64", "386"] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v2') needs: build steps: - name: Checkout uses: actions/checkout@v6 - name: Get Tag run: echo "TAG=${GITHUB_REF#refs/*/v}" >> $GITHUB_ENV - name: Is Unix Platform run: echo "IS_UNIX=${{ matrix.os != 'windows' && matrix.arch != '386' && (matrix.os != 'linux' || matrix.arch != 'arm64') }}" >> $GITHUB_ENV - name: Is Windows Platform run: echo "IS_WIN=${{ matrix.os == 'windows' }}" >> $GITHUB_ENV - name: Setup Go uses: actions/setup-go@v6 with: go-version-file: "go.mod" - name: Build if: env.IS_UNIX == 'true' || env.IS_WIN == 'true' run: | go build -ldflags "-X main.version=${{ env.TAG }}" -o "./spicetify${{ matrix.os == 'windows' && '.exe' || '' }}" chmod +x "./spicetify${{ matrix.os == 'windows' && '.exe' || '' }}" env: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} CGO_ENABLED: 0 - name: Upload Artifact for Signing if: env.IS_WIN == 'true' id: upload-artifact-for-signing uses: actions/upload-artifact@v6 with: name: spicetify-${{ env.TAG }}-${{ matrix.os }}-${{ (matrix.arch == 'amd64' && 'x64') || (matrix.arch == 'arm64' && 'arm64') || 'x32' }}-unsigned path: ./spicetify.exe - name: Sign Windows Executable if: env.IS_WIN == 'true' uses: signpath/github-action-submit-signing-request@v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: ${{ secrets.SIGNPATH_ORG_ID }} project-slug: "cli" signing-policy-slug: "release-signing" github-artifact-id: ${{ steps.upload-artifact-for-signing.outputs.artifact-id }} wait-for-completion: true output-artifact-directory: "./signed" - name: Copy Signed Windows Executable if: env.IS_WIN == 'true' run: | cp ./signed/spicetify.exe ./spicetify.exe - name: Attest output uses: actions/attest-build-provenance@v4 if: env.IS_UNIX == 'true' || env.IS_WIN == 'true' with: subject-path: "./spicetify${{ matrix.os == 'windows' && '.exe' || '' }}" subject-name: "spicetify v${{ env.TAG }} (${{ matrix.os }}, ${{ (matrix.os == 'windows' && matrix.arch == 'amd64' && 'x64') || (matrix.os == 'windows' && matrix.arch == '386' && 'x32') || matrix.arch }})" - name: 7z - .tar if: env.IS_UNIX == 'true' uses: edgarrc/action-7z@v1 with: args: 7z a -bb0 "spicetify-${{ env.TAG }}-${{ matrix.os }}-${{ matrix.arch }}.tar" "./spicetify" "./CustomApps" "./Extensions" "./Themes" "./jsHelper" "globals.d.ts" "css-map.json" - name: 7z - .tar.gz if: env.IS_UNIX == 'true' uses: edgarrc/action-7z@v1 with: args: 7z a -bb0 -sdel -mx9 "spicetify-${{ env.TAG }}-${{ matrix.os }}-${{ matrix.arch }}.tar.gz" "spicetify-${{ env.TAG }}-${{ matrix.os }}-${{ matrix.arch }}.tar" - name: 7z - .zip if: env.IS_WIN == 'true' uses: edgarrc/action-7z@v1 with: 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" - name: Release if: env.IS_UNIX == 'true' || env.IS_WIN == 'true' uses: softprops/action-gh-release@v2 with: 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' }}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} trigger-release: name: Trigger Homebrew/AUR Release runs-on: ubuntu-latest needs: release steps: - name: Update AUR package uses: fjogeleit/http-request-action@master with: url: https://spicetify-update.itsmeow.dev/spicetify-update method: GET - name: Update Winget package uses: vedantmgoyal9/winget-releaser@main with: identifier: Spicetify.Spicetify installers-regex: '-windows-\w+\.zip$' token: ${{ secrets.SPICETIFY_WINGET_TOKEN }} ================================================ FILE: .github/workflows/labeler.yml ================================================ name: Issue Labeler on: issues: types: [opened, edited] jobs: triage: runs-on: ubuntu-latest steps: - uses: github/issue-labeler@v3.4 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" configuration-path: .github/labeler.yml enable-versioned-regex: 0 ================================================ FILE: .github/workflows/linter.yml ================================================ name: Code quality on: pull_request: branches: - "main" - "*/main/*/**" push: branches: - "main" - "*/main/*/**" jobs: linter: runs-on: ubuntu-latest steps: - name: Checkout the repo uses: actions/checkout@v6 - name: Setup Biome uses: biomejs/setup-biome@v2 with: version: latest - name: Run Biome run: biome ci . --files-ignore-unknown=true --diagnostic-level=error ================================================ FILE: .github/workflows/lintpr.yml ================================================ name: Lint Pull Request on: pull_request_target: types: [opened, edited, synchronize] jobs: lintpr: runs-on: ubuntu-latest steps: - name: Lint pull request title uses: amannn/action-semantic-pull-request@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: disallowScopes: | [A-Z]+ subjectPattern: ^(?![A-Z]).+$ ================================================ FILE: .gitignore ================================================ # Executables bin cli spicetify spicetify-cli *.exe # MacOS .DS_Store # Node.js node_modules package-lock.json package.json # Logs install.log pnpm-lock.yaml ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["timonwong.shellcheck", "biomejs.biome", "golang.go", "ms-vscode.powershell"] } ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true, "[go]": { "editor.defaultFormatter": "golang.go" }, "[powershell]": { "editor.defaultFormatter": "ms-vscode.powershell" }, "[javascript][typescript][json]": { "editor.defaultFormatter": "biomejs.biome" } } ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Spicetify-cli ## Table of Contents - [I Have a Question](#i-have-a-question) - [How to Contribute](#how-to-contribute) - [Reporting Bugs](#reporting-bugs) - [Suggesting Enhancements](#suggesting-enhancements) - [Your First Code Contribution](#your-first-code-contribution) - [Improving The Documentation](#improving-the-documentation) - [Commit Message Format](#commit-message-format) ## I Have a Question > If you want to ask a question, we assume that you have read the available [Documentation](https://spicetify.app/docs/getting-started/). Before you ask a question, it is best to search for existing [issues](https://github.com/spicetify/cli/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. If you then still feel the need to ask a question and need clarification, we recommend the following: - Open an [issue](https://github.com/spicetify/cli/issues/new). - Provide both Spicetify and Spotify version. - Explain what the problem is. We will then take care of the issue as soon as possible. ## How to Contribute > ### Legal Notice > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. ### Reporting Bugs #### Before Submitting a Bug Report A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. - Make sure that you are using the latest version. - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://spicetify.app/docs/getting-started/). If you are looking for support, you might want to check [this section](#i-have-a-question)). - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/spicetify/cli/labels/%F0%9F%90%9B%20bug). #### How Do I Submit a Good Bug Report? We use GitHub issues to track bugs and errors. If you run into an issue with the project: - Open an [issue](https://github.com/spicetify/cli/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) - Use the provided [bug template](https://github.com/spicetify/cli/issues/new?assignees=&labels=%F0%9F%90%9B+bug&projects=&template=bug_report.yml). - Explain the behavior you would expect and the actual behavior. - Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. - Provide the information you collected in the previous section. ### Suggesting Enhancements This section guides you through submitting an enhancement suggestion for spicetify, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. #### Before Submitting an Enhancement - Make sure that you are using the latest version. - Read the [documentation](https://spicetify.app/docs/getting-started/) carefully and find out if the functionality is already covered, maybe by an individual configuration. - Perform a [search](https://github.com/spicetify/cli/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. #### How Do I Submit a Good Enhancement Suggestion? Enhancement suggestions are tracked as [GitHub issues](https://github.com/spicetify/cli/issues). Create an enhancement suggestion using the provided [feature request template](https://github.com/spicetify/cli/issues/new?assignees=&labels=%E2%9C%A8+feature&projects=&template=feature_request.yml). - Use a **clear and descriptive title** for the issue to identify the suggestion. - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. - For GUIs, you may want to **include screenshots** which help you demonstrate the steps or point out the part which the suggestion is related to. Animated GIFS and videos may be helpful but are not expected. Some tools available are the [built-in screen recorder](https://support.apple.com/en-us/102618) on macOS, [LICEcap](https://www.cockos.com/licecap/) on macOS and Windows, and [ShareX](https://getsharex.com/) on Linux. - **Explain why this enhancement would be useful** to most spicetify users. You may also want to point out the other projects that solved it better and which could serve as inspiration. ### Your First Code Contribution #### Requirements - [Go](https://go.dev/dl/) #### Environment Setup and Development Follow the steps outlined in the [documentation](https://spicetify.app/docs/development/compiling) or the steps below. 1. Clone the repository using `git clone https://github.com/spicetify/cli`. 2. Enter the repository directory and build the project. * Windows ``` cd cli go build -o spicetify.exe ``` * Linux and MacOS ``` cd cli go build -o spicetify ``` 3. Execute the executable file generated by `go build` using `./spicetify` or `./spicetify.exe`. ### Improving The Documentation To improve the [documentation](https://spicetify.app/docs/getting-started), navigate to the documentation [repository](https://github.com/spicetify/docs). ### Commit Message Format (): [optional] * **type:** feat | fix | docs | chore | revert * **feat:** A new feature * **fix:** A bug fix * **docs:** Documentation only changes * **chore:** Changes to build process, auxiliary tools, libraries, and other things * **revert:** A reversion to a previous commit * **scope:** Anything specifying place of the commit change * **subject:** What changes you have done * Use the imperative, present tense: "change" not "changed" nor "changes" * Don't capitalize first letter * No dot (.) at the end * **body**: More details of your changes, you can mention the most important changes here * Use the imperative, present tense: "change" not "changed" nor "changes" If you want to learn more, view the [Angular - Git Commit Guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines). ================================================ FILE: CustomApps/lyrics-plus/OptionsMenu.js ================================================ const OptionsMenuItemIcon = react.createElement( "svg", { width: 16, height: 16, viewBox: "0 0 16 16", fill: "currentColor", }, react.createElement("path", { d: "M13.985 2.383L5.127 12.754 1.388 8.375l-.658.77 4.397 5.149 9.618-11.262z", }) ); const OptionsMenuItem = react.memo(({ onSelect, value, isSelected }) => { return react.createElement( Spicetify.ReactComponent.MenuItem, { onClick: onSelect, icon: isSelected ? OptionsMenuItemIcon : null, trailingIcon: isSelected ? OptionsMenuItemIcon : null, }, value ); }); const OptionsMenu = react.memo(({ options, onSelect, selected, defaultValue, bold = false }) => { /** * ) } * > * * */ const menuRef = react.useRef(null); return react.createElement( Spicetify.ReactComponent.ContextMenu, { menu: react.createElement( Spicetify.ReactComponent.Menu, {}, options.map(({ key, value }) => react.createElement(OptionsMenuItem, { value, onSelect: () => { onSelect(key); // Close menu on item click menuRef.current?.click(); }, isSelected: selected?.key === key, }) ) ), trigger: "click", action: "toggle", renderInline: false, }, react.createElement( "button", { className: "optionsMenu-dropBox", ref: menuRef, }, react.createElement( "span", { className: bold ? "main-type-mestoBold" : "main-type-mesto", }, selected?.value || defaultValue ), react.createElement( "svg", { height: "16", width: "16", fill: "currentColor", viewBox: "0 0 16 16", }, react.createElement("path", { d: "M3 6l5 5.794L13 6z", }) ) ) ); }); function getMusixmatchTranslationPrefix() { if (typeof window !== "undefined" && typeof window.__lyricsPlusMusixmatchTranslationPrefix === "string") { return window.__lyricsPlusMusixmatchTranslationPrefix; } return "musixmatchTranslation:"; } const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation, musixmatchLanguages, musixmatchSelectedLanguage }) => { const musixmatchTranslationPrefix = getMusixmatchTranslationPrefix(); const [languageMap, setLanguageMap] = react.useState({}); react.useEffect(() => { let cancelled = false; if (typeof ProviderMusixmatch !== "undefined" && ProviderMusixmatch && typeof ProviderMusixmatch.getLanguages === "function") { (async () => { try { const languages = await ProviderMusixmatch.getLanguages(); if (!cancelled) { setLanguageMap(languages); } } catch (error) { console.error("Failed to fetch Musixmatch languages:", error); } })(); } return () => { cancelled = true; }; }, []); const items = useMemo(() => { let sourceOptions = { none: "None", }; const translationDisplayOptions = { replace: "Replace original", below: "Below original", }; const languageOptions = { off: "Off", "zh-hans": "Chinese (Simplified)", "zh-hant": "Chinese (Traditional)", ja: "Japanese", ko: "Korean", }; let modeOptions = { none: "None", }; const musixmatchDisplay = new Intl.DisplayNames(["en"], { type: "language" }); const availableMusixmatchLanguages = Array.isArray(musixmatchLanguages) ? [...new Set(musixmatchLanguages.filter(Boolean))] : []; const activeMusixmatchLanguage = musixmatchSelectedLanguage && musixmatchSelectedLanguage !== "none" ? musixmatchSelectedLanguage : null; if (hasTranslation.musixmatch && activeMusixmatchLanguage) { availableMusixmatchLanguages.push(activeMusixmatchLanguage); } if (availableMusixmatchLanguages.length) { const musixmatchOptionsArray = availableMusixmatchLanguages.map((code) => { let label = ""; try { if (languageMap && languageMap[code]) { label = languageMap[code]; } else { label = musixmatchDisplay.of(code) ?? code.toUpperCase(); } } catch (e) { label = code.toUpperCase(); } return { key: `${musixmatchTranslationPrefix}${code}`, label: `${label} (Musixmatch)`, }; }); musixmatchOptionsArray.sort((a, b) => a.label.localeCompare(b.label)); const musixmatchOptions = musixmatchOptionsArray.reduce((acc, { key, label }) => { acc[key] = label; return acc; }, {}); sourceOptions = { ...sourceOptions, ...musixmatchOptions }; } if (hasTranslation.netease) { sourceOptions = { ...sourceOptions, neteaseTranslation: "Chinese (Netease)", }; } switch (friendlyLanguage) { case "japanese": { modeOptions = { furigana: "Furigana", romaji: "Romaji", hiragana: "Hiragana", katakana: "Katakana", }; break; } case "korean": { modeOptions = { romaja: "Romaja", }; break; } case "chinese": { modeOptions = { cn: "Simplified Chinese", hk: "Traditional Chinese (Hong Kong)", tw: "Traditional Chinese (Taiwan)", }; break; } } const configItems = [ { desc: "Translation Provider", key: "translate:translated-lyrics-source", type: ConfigSelection, options: sourceOptions, renderInline: true, }, { desc: "Translation Display", key: "translate:display-mode", type: ConfigSelection, options: translationDisplayOptions, renderInline: true, }, { desc: "Language Override", key: "translate:detect-language-override", type: ConfigSelection, options: languageOptions, renderInline: true, // for songs in languages that support translation but not Convert (e.g., English), the option is disabled. when: () => friendlyLanguage, }, { desc: "Display Mode", key: `translation-mode:${friendlyLanguage}`, type: ConfigSelection, options: modeOptions, renderInline: true, // for songs in languages that support translation but not Convert (e.g., English), the option is disabled. when: () => friendlyLanguage, }, { desc: "Convert", key: "translate", type: ConfigSlider, trigger: "click", action: "toggle", renderInline: true, // for songs in languages that support translation but not Convert (e.g., English), the option is disabled. when: () => friendlyLanguage, }, ]; return configItems; }, [ friendlyLanguage, hasTranslation.musixmatch, hasTranslation.netease, Array.isArray(musixmatchLanguages) ? musixmatchLanguages.join(",") : "", musixmatchSelectedLanguage || "", musixmatchTranslationPrefix, languageMap, ]); useEffect(() => { // Currently opened Context Menu does not receive prop changes // If we were to use keys the Context Menu would close on re-render const event = new CustomEvent("lyrics-plus", { detail: { type: "translation-menu", items, }, }); document.dispatchEvent(event); }, [friendlyLanguage, items]); return react.createElement( Spicetify.ReactComponent.TooltipWrapper, { label: "Conversion", }, react.createElement( "div", { className: "lyrics-tooltip-wrapper", }, react.createElement( Spicetify.ReactComponent.ContextMenu, { menu: react.createElement( Spicetify.ReactComponent.Menu, {}, react.createElement("h3", null, " Conversions"), react.createElement(OptionList, { type: "translation-menu", items, onChange: (name, value) => { if (name === "translate") { CONFIG.visual["translate:translated-lyrics-source"] = "none"; localStorage.setItem(`${APP_NAME}:visual:translate:translated-lyrics-source`, "none"); } if (name === "translate:translated-lyrics-source") { const hasTranslationProvider = typeof value === "string" && value !== "none"; if (hasTranslationProvider && CONFIG.visual.translate) { CONFIG.visual.translate = false; localStorage.setItem(`${APP_NAME}:visual:translate`, "false"); } let nextMusixmatchLanguage = "none"; if (typeof value === "string" && value.startsWith(musixmatchTranslationPrefix)) { nextMusixmatchLanguage = value.slice(musixmatchTranslationPrefix.length) || "none"; } if (CONFIG.visual["musixmatch-translation-language"] !== nextMusixmatchLanguage) { CONFIG.visual["musixmatch-translation-language"] = nextMusixmatchLanguage; localStorage.setItem(`${APP_NAME}:visual:musixmatch-translation-language`, nextMusixmatchLanguage); } } CONFIG.visual[name] = value; localStorage.setItem(`${APP_NAME}:visual:${name}`, value); lyricContainerUpdate?.(); }, }) ), trigger: "click", action: "toggle", renderInline: true, }, react.createElement( "button", { className: "lyrics-config-button", }, react.createElement( "p1", { width: 16, height: 16, viewBox: "0 0 16 10.3", fill: "currentColor", }, "⇄" ) ) ) ) ); }); const AdjustmentsMenu = react.memo(({ mode, hasPerformer }) => { return react.createElement( Spicetify.ReactComponent.TooltipWrapper, { label: "Adjustments", }, react.createElement( "div", { className: "lyrics-tooltip-wrapper", }, react.createElement( Spicetify.ReactComponent.ContextMenu, { menu: react.createElement( Spicetify.ReactComponent.Menu, {}, react.createElement("h3", null, " Adjustments"), react.createElement(OptionList, { items: [ { desc: "Font size", key: "font-size", type: ConfigAdjust, min: fontSizeLimit.min, max: fontSizeLimit.max, step: fontSizeLimit.step, }, { desc: "Track delay", key: "delay", type: ConfigAdjust, min: Number.NEGATIVE_INFINITY, max: Number.POSITIVE_INFINITY, step: 250, when: () => mode === SYNCED || mode === KARAOKE, }, { desc: "Compact", key: "synced-compact", type: ConfigSlider, when: () => mode === SYNCED || mode === KARAOKE, }, { desc: "Show performers", key: "show-performers", type: ConfigSlider, when: () => hasPerformer && (mode === SYNCED || mode === KARAOKE || mode === UNSYNCED), }, { desc: "Dual panel", key: "dual-genius", type: ConfigSlider, when: () => mode === GENIUS, }, ], onChange: (name, value) => { CONFIG.visual[name] = value; localStorage.setItem(`${APP_NAME}:visual:${name}`, value); name === "delay" && localStorage.setItem(`lyrics-delay:${Spicetify.Player.data.item.uri}`, value); lyricContainerUpdate?.(); }, }) ), trigger: "click", action: "toggle", renderInline: true, }, react.createElement( "button", { className: "lyrics-config-button", }, react.createElement( "svg", { width: 16, height: 16, viewBox: "0 0 16 10.3", fill: "currentColor", }, react.createElement("path", { d: "M 10.8125,0 C 9.7756347,0 8.8094481,0.30798341 8,0.836792 7.1905519,0.30798341 6.2243653,0 5.1875,0 2.3439941,0 0,2.3081055 0,5.15625 0,8.0001222 2.3393555,10.3125 5.1875,10.3125 6.2243653,10.3125 7.1905519,10.004517 8,9.4757081 8.8094481,10.004517 9.7756347,10.3125 10.8125,10.3125 13.656006,10.3125 16,8.0043944 16,5.15625 16,2.3123779 13.660644,0 10.8125,0 Z M 8,2.0146484 C 8.2629394,2.2503662 8.4963378,2.5183106 8.6936034,2.8125 H 7.3063966 C 7.5036622,2.5183106 7.7370606,2.2503662 8,2.0146484 Z M 6.619995,4.6875 C 6.6560059,4.3625487 6.7292481,4.0485841 6.8350831,3.75 h 2.3298338 c 0.1059572,0.2985841 0.1790772,0.6125487 0.21521,0.9375 z M 9.380005,5.625 C 9.3439941,5.9499512 9.2707519,6.2639159 9.1649169,6.5625 H 6.8350831 C 6.7291259,6.2639159 6.6560059,5.9499512 6.6198731,5.625 Z M 5.1875,9.375 c -2.3435059,0 -4.25,-1.8925781 -4.25,-4.21875 0,-2.3261719 1.9064941,-4.21875 4.25,-4.21875 0.7366944,0 1.4296875,0.1899414 2.0330809,0.5233154 C 6.2563478,2.3981934 5.65625,3.7083741 5.65625,5.15625 c 0,1.4478759 0.6000978,2.7580566 1.5643309,3.6954347 C 6.6171875,9.1850584 5.9241944,9.375 5.1875,9.375 Z M 8,8.2978516 C 7.7370606,8.0621337 7.5036622,7.7938231 7.3063966,7.4996337 H 8.6936034 C 8.4963378,7.7938231 8.2629394,8.0621338 8,8.2978516 Z M 10.8125,9.375 C 10.075806,9.375 9.3828125,9.1850584 8.7794191,8.8516847 9.7436522,7.9143066 10.34375,6.6041259 10.34375,5.15625 10.34375,3.7083741 9.7436522,2.3981934 8.7794191,1.4608154 9.3828125,1.1274414 10.075806,0.9375 10.8125,0.9375 c 2.343506,0 4.25,1.8925781 4.25,4.21875 0,2.3261719 -1.906494,4.21875 -4.25,4.21875 z m 0,0", }) ) ) ) ) ); }); ================================================ FILE: CustomApps/lyrics-plus/Pages.js ================================================ const CreditFooter = react.memo(({ provider, copyright }) => { if (provider === "local") return null; const credit = [Spicetify.Locale.get("web-player.lyrics.providedBy", provider)]; if (copyright) { credit.push(...copyright.split("\n")); } return ( provider && react.createElement( "p", { className: "lyrics-lyricsContainer-Provider main-type-mesto", dir: "auto", }, credit.join(" • ") ) ); }); const IdlingIndicator = ({ isActive, progress, delay }) => { return react.createElement( "div", { className: `lyrics-idling-indicator ${ !isActive ? "lyrics-idling-indicator-hidden" : "" } lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active`, style: { "--position-index": 0, "--animation-index": 1, "--indicator-delay": `${delay}ms`, }, }, react.createElement("div", { className: `lyrics-idling-indicator__circle ${progress >= 0.05 ? "active" : ""}` }), react.createElement("div", { className: `lyrics-idling-indicator__circle ${progress >= 0.33 ? "active" : ""}` }), react.createElement("div", { className: `lyrics-idling-indicator__circle ${progress >= 0.66 ? "active" : ""}` }) ); }; const emptyLine = { startTime: 0, endTime: 0, text: [], }; const useTrackPosition = (callback) => { const callbackRef = useRef(); callbackRef.current = callback; useEffect(() => { const interval = setInterval(callbackRef.current, 50); return () => { clearInterval(interval); }; }, [callbackRef]); }; const KaraokeLine = ({ text, isActive, position, startTime, endTime }) => { if (endTime && position > endTime) { return text.map(({ word }) => word).join(""); } return text.map(({ word, time }, i) => { const isWordActive = position >= startTime; startTime += time; return react.createElement( "span", { key: i, className: `lyrics-lyricsContainer-Karaoke-Word${isWordActive ? " lyrics-lyricsContainer-Karaoke-WordActive" : ""}`, style: { "--word-duration": `${time}ms`, // don't animate unless we have to transition: !isWordActive ? "all 0s linear" : "", }, }, word ); }); }; const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara }) => { const [position, setPosition] = useState(0); const activeLineEle = useRef(); const lyricContainerEle = useRef(); useTrackPosition(() => { const newPos = Spicetify.Player.getProgress(); const delay = CONFIG.visual["global-delay"] + CONFIG.visual.delay; if (newPos !== position) { setPosition(newPos + delay); } }); const lyricWithEmptyLines = useMemo( () => [emptyLine, emptyLine, ...lyrics].map((line, i) => ({ ...line, lineNumber: i, })), [lyrics] ); const lyricsId = lyrics[0].text; let activeLineIndex = 0; for (let i = lyricWithEmptyLines.length - 1; i > 0; i--) { if (position >= lyricWithEmptyLines[i].startTime) { activeLineIndex = i; break; } } const activeLines = useMemo(() => { const startIndex = Math.max(activeLineIndex - 1 - CONFIG.visual["lines-before"], 0); // 3 lines = 1 padding top + 1 padding bottom + 1 active const linesCount = CONFIG.visual["lines-before"] + CONFIG.visual["lines-after"] + 3; return lyricWithEmptyLines.slice(startIndex, startIndex + linesCount); }, [activeLineIndex, lyricWithEmptyLines]); let offset = lyricContainerEle.current ? lyricContainerEle.current.clientHeight / 2 : 0; if (activeLineEle.current) { offset += -(activeLineEle.current.offsetTop + activeLineEle.current.clientHeight / 2); } return react.createElement( "div", { className: "lyrics-lyricsContainer-SyncedLyricsPage", ref: lyricContainerEle, }, react.createElement( "div", { className: "lyrics-lyricsContainer-SyncedLyrics", style: { "--offset": `${offset}px`, }, key: lyricsId, }, activeLines.map(({ text, lineNumber, startTime, endTime, originalText, performer }, i) => { if (i === 1 && activeLineIndex === 1) { return react.createElement(IdlingIndicator, { progress: position / activeLines[2].startTime, delay: activeLines[2].startTime / 3, }); } let className = "lyrics-lyricsContainer-LyricsLine"; const activeElementIndex = Math.min(activeLineIndex, CONFIG.visual["lines-before"] + 1); let ref; const isActive = activeElementIndex === i; if (isActive) { className += " lyrics-lyricsContainer-LyricsLine-active"; ref = activeLineEle; } let animationIndex; if (activeLineIndex <= CONFIG.visual["lines-before"]) { animationIndex = i - activeLineIndex; } else { animationIndex = i - CONFIG.visual["lines-before"] - 1; } const paddingLine = (animationIndex < 0 && -animationIndex > CONFIG.visual["lines-before"]) || animationIndex > CONFIG.visual["lines-after"]; if (paddingLine) { className += " lyrics-lyricsContainer-LyricsLine-paddingLine"; } const showTranslatedBelow = CONFIG.visual["translate:display-mode"] === "below"; // If we have original text and we are showing translated below, we should show the original text // Otherwise we should show the translated text const lineText = originalText && showTranslatedBelow ? originalText : text; // Convert lyrics to text for comparison const belowOrigin = (typeof originalText === "object" ? originalText?.props?.children?.[0] : originalText)?.replace(/\s+/g, ""); const belowTxt = (typeof text === "object" ? text?.props?.children?.[0] : text)?.replace(/\s+/g, ""); const belowMode = showTranslatedBelow && originalText && belowOrigin !== belowTxt; return react.createElement( "div", { className, style: { cursor: "pointer", "--position-index": animationIndex, "--animation-index": (animationIndex < 0 ? 0 : animationIndex) + 1, "--blur-index": Math.abs(animationIndex), }, dir: "auto", ref, key: lineNumber, onClick: (event) => { if (startTime) { Spicetify.Player.seek(startTime); } }, }, react.createElement( "p", { onContextMenu: (event) => { event.preventDefault(); Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToLRC(lyrics, belowMode).original) .then(() => Spicetify.showNotification("Lyrics copied to clipboard")) .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard")); }, }, (() => { if (!CONFIG.visual["show-performers"] || !performer) return null; if (!CONFIG.visual["synced-compact"]) { const previousLine = lyricWithEmptyLines[lineNumber - 1]; if (previousLine && previousLine.performer === performer) return null; } return react.createElement( "span", { className: "lyrics-lyricsContainer-Performer", }, performer ); })(), !isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, endTime, position, isActive }) ), belowMode && react.createElement( "p", { style: { opacity: 0.5, }, onContextMenu: (event) => { event.preventDefault(); Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToLRC(lyrics, belowMode).conver) .then(() => Spicetify.showNotification("Translated lyrics copied to clipboard")) .catch(() => Spicetify.showNotification("Failed to copy translated lyrics to clipboard")); }, }, text ) ); }) ), react.createElement(CreditFooter, { provider, copyright, }) ); }); class SearchBar extends react.Component { constructor() { super(); this.state = { hidden: true, atNode: 0, foundNodes: [], }; this.container = null; } componentDidMount() { this.viewPort = document.querySelector(".main-view-container .os-viewport"); this.mainViewOffsetTop = document.querySelector(".Root__main-view").offsetTop; this.toggleCallback = () => { if (!(Spicetify.Platform.History.location.pathname === "/lyrics-plus" && this.container)) return; if (this.state.hidden) { this.setState({ hidden: false }); this.container.focus(); } else { this.setState({ hidden: true }); this.container.blur(); } }; this.unFocusCallback = () => { this.container.blur(); this.setState({ hidden: true }); }; this.loopThroughCallback = (event) => { if (!this.state.foundNodes.length) { return; } if (event.key === "Enter") { const dir = event.shiftKey ? -1 : 1; let atNode = this.state.atNode + dir; if (atNode < 0) { atNode = this.state.foundNodes.length - 1; } atNode %= this.state.foundNodes.length; const rects = this.state.foundNodes[atNode].getBoundingClientRect(); this.viewPort.scrollBy(0, rects.y - 100); this.setState({ atNode }); } }; Spicetify.Mousetrap().bind("mod+shift+f", this.toggleCallback); Spicetify.Mousetrap(this.container).bind("mod+shift+f", this.toggleCallback); Spicetify.Mousetrap(this.container).bind("enter", this.loopThroughCallback); Spicetify.Mousetrap(this.container).bind("shift+enter", this.loopThroughCallback); Spicetify.Mousetrap(this.container).bind("esc", this.unFocusCallback); } componentWillUnmount() { Spicetify.Mousetrap().unbind("mod+shift+f", this.toggleCallback); Spicetify.Mousetrap(this.container).unbind("mod+shift+f", this.toggleCallback); Spicetify.Mousetrap(this.container).unbind("enter", this.loopThroughCallback); Spicetify.Mousetrap(this.container).unbind("shift+enter", this.loopThroughCallback); Spicetify.Mousetrap(this.container).unbind("esc", this.unFocusCallback); } getNodeFromInput(event) { const value = event.target.value.toLowerCase(); if (!value) { this.setState({ foundNodes: [] }); this.viewPort.scrollTo(0, 0); return; } const lyricsPage = document.querySelector(".lyrics-lyricsContainer-UnsyncedLyricsPage"); const walker = document.createTreeWalker( lyricsPage, NodeFilter.SHOW_TEXT, (node) => { if (node.textContent.toLowerCase().includes(value)) { return NodeFilter.FILTER_ACCEPT; } return NodeFilter.FILTER_REJECT; }, false ); const foundNodes = []; while (walker.nextNode()) { const range = document.createRange(); range.selectNodeContents(walker.currentNode); foundNodes.push(range); } if (!foundNodes.length) { this.viewPort.scrollBy(0, 0); } else { const rects = foundNodes[0].getBoundingClientRect(); this.viewPort.scrollBy(0, rects.y - 100); } this.setState({ foundNodes, atNode: 0 }); } render() { let y = 0; let height = 0; if (this.state.foundNodes.length) { const node = this.state.foundNodes[this.state.atNode]; const rects = node.getBoundingClientRect(); y = rects.y + this.viewPort.scrollTop - this.mainViewOffsetTop; height = rects.height; } return react.createElement( "div", { className: `lyrics-Searchbar${this.state.hidden ? " hidden" : ""}`, }, react.createElement("input", { ref: (c) => { this.container = c; }, onChange: this.getNodeFromInput.bind(this), }), react.createElement("svg", { width: 16, height: 16, viewBox: "0 0 16 16", fill: "currentColor", dangerouslySetInnerHTML: { __html: Spicetify.SVGIcons.search, }, }), react.createElement( "span", { hidden: this.state.foundNodes.length === 0, }, `${this.state.atNode + 1}/${this.state.foundNodes.length}` ), react.createElement("div", { className: "lyrics-Searchbar-highlight", style: { "--search-highlight-top": `${y}px`, "--search-highlight-height": `${height}px`, }, }) ); } } function isInViewport(element) { const rect = element.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); } const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKara }) => { const [position, setPosition] = useState(0); const activeLineRef = useRef(null); const pageRef = useRef(null); useTrackPosition(() => { if (!Spicetify.Player.data.is_paused) { setPosition(Spicetify.Player.getProgress() + CONFIG.visual["global-delay"] + CONFIG.visual.delay); } }); const padded = useMemo(() => [emptyLine, ...lyrics], [lyrics]); const intialScroll = useMemo(() => [false], [lyrics]); const lyricsId = lyrics[0].text; let activeLineIndex = 0; for (let i = padded.length - 1; i >= 0; i--) { const line = padded[i]; if (position >= line.startTime) { activeLineIndex = i; break; } } useEffect(() => { if (activeLineRef.current && (!intialScroll[0] || isInViewport(activeLineRef.current))) { activeLineRef.current.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest", }); intialScroll[0] = true; } }, [activeLineRef.current]); return react.createElement( "div", { className: "lyrics-lyricsContainer-UnsyncedLyricsPage", key: lyricsId, ref: pageRef, }, react.createElement("p", { className: "lyrics-lyricsContainer-LyricsUnsyncedPadding", }), padded.map(({ text, startTime, endTime, originalText, performer }, i) => { if (i === 0) { return react.createElement(IdlingIndicator, { isActive: activeLineIndex === 0, progress: position / padded[1].startTime, delay: padded[1].startTime / 3, }); } const isActive = i === activeLineIndex; const showTranslatedBelow = CONFIG.visual["translate:display-mode"] === "below"; // If we have original text and we are showing translated below, we should show the original text // Otherwise we should show the translated text const lineText = originalText && showTranslatedBelow ? originalText : text; // Convert lyrics to text for comparison const belowOrigin = (typeof originalText === "object" ? originalText?.props?.children?.[0] : originalText)?.replace(/\s+/g, ""); const belowTxt = (typeof text === "object" ? text?.props?.children?.[0] : text)?.replace(/\s+/g, ""); const belowMode = showTranslatedBelow && originalText && belowOrigin !== belowTxt; return react.createElement( "div", { className: `lyrics-lyricsContainer-LyricsLine${i <= activeLineIndex ? " lyrics-lyricsContainer-LyricsLine-active" : ""}`, key: i, style: { cursor: "pointer", }, dir: "auto", ref: isActive ? activeLineRef : null, onClick: (event) => { if (startTime) { Spicetify.Player.seek(startTime); } }, }, react.createElement( "p", { onContextMenu: (event) => { event.preventDefault(); Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToLRC(lyrics, belowMode).original) .then(() => Spicetify.showNotification("Lyrics copied to clipboard")) .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard")); }, }, (() => { if (!CONFIG.visual["show-performers"] || !performer) return null; if (!CONFIG.visual["synced-compact"]) { const previousLine = padded[i - 1]; if (previousLine && previousLine.performer === performer) return null; } return react.createElement( "span", { className: "lyrics-lyricsContainer-Performer", }, performer ); })(), !isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, endTime, position, isActive }) ), belowMode && react.createElement( "p", { style: { opacity: 0.5 }, onContextMenu: (event) => { event.preventDefault(); Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToLRC(lyrics, belowMode).conver) .then(() => Spicetify.showNotification("Translated lyrics copied to clipboard")) .catch(() => Spicetify.showNotification("Failed to copy translated lyrics to clipboard")); }, }, text ) ); }), react.createElement("p", { className: "lyrics-lyricsContainer-LyricsUnsyncedPadding", }), react.createElement(CreditFooter, { provider, copyright, }), react.createElement(SearchBar, null) ); }); const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => { return react.createElement( "div", { className: "lyrics-lyricsContainer-UnsyncedLyricsPage", }, react.createElement("p", { className: "lyrics-lyricsContainer-LyricsUnsyncedPadding", }), lyrics.map(({ text, originalText, performer }, index) => { const showTranslatedBelow = CONFIG.visual["translate:display-mode"] === "below"; // If we have original text and we are showing translated below, we should show the original text // Otherwise we should show the translated text const lineText = originalText && showTranslatedBelow ? originalText : text; // Convert lyrics to text for comparison const belowOrigin = (typeof originalText === "object" ? originalText?.props?.children?.[0] : originalText)?.replace(/\s+/g, ""); const belowTxt = (typeof text === "object" ? text?.props?.children?.[0] : text)?.replace(/\s+/g, ""); const belowMode = showTranslatedBelow && originalText && belowOrigin !== belowTxt; return react.createElement( "div", { className: "lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active", key: index, dir: "auto", }, react.createElement( "p", { onContextMenu: (event) => { event.preventDefault(); Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToUnsynced(lyrics, belowMode).original) .then(() => Spicetify.showNotification("Lyrics copied to clipboard")) .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard")); }, }, (() => { if (!CONFIG.visual["show-performers"] || !performer) return null; const previousLine = lyrics[index - 1]; if (previousLine && previousLine.performer === performer) return null; return react.createElement( "span", { className: "lyrics-lyricsContainer-Performer", }, performer ); })(), lineText ), belowMode && react.createElement( "p", { style: { opacity: 0.5 }, onContextMenu: (event) => { event.preventDefault(); Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToUnsynced(lyrics, belowMode).conver) .then(() => Spicetify.showNotification("Translated lyrics copied to clipboard")) .catch(() => Spicetify.showNotification("Failed to copy translated lyrics to clipboard")); }, }, text ) ); }), react.createElement("p", { className: "lyrics-lyricsContainer-LyricsUnsyncedPadding", }), react.createElement(CreditFooter, { provider, copyright, }), react.createElement(SearchBar, null) ); }); const noteContainer = document.createElement("div"); noteContainer.classList.add("lyrics-Genius-noteContainer"); const noteDivider = document.createElement("div"); noteDivider.classList.add("lyrics-Genius-divider"); noteDivider.innerHTML = ``; noteDivider.style.setProperty("--link-left", 0); const noteTextContainer = document.createElement("div"); noteTextContainer.classList.add("lyrics-Genius-noteTextContainer"); noteTextContainer.onclick = (event) => { event.preventDefault(); event.stopPropagation(); }; noteContainer.append(noteDivider, noteTextContainer); function showNote(parent, note) { if (noteContainer.parentElement === parent) { noteContainer.remove(); return; } noteTextContainer.innerText = note; parent.append(noteContainer); const arrowPos = parent.offsetLeft - noteContainer.offsetLeft; noteDivider.style.setProperty("--link-left", `${arrowPos}px`); const box = noteTextContainer.getBoundingClientRect(); if (box.y + box.height > window.innerHeight) { // Wait for noteContainer is mounted setTimeout(() => { noteContainer.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest", }); }, 50); } } const GeniusPage = react.memo( ({ lyrics, provider, copyright, versions, versionIndex, onVersionChange, isSplitted, lyrics2, versionIndex2, onVersionChange2 }) => { let notes = {}; let container = null; let container2 = null; // Fetch notes useEffect(() => { if (!container) return; notes = {}; let links = container.querySelectorAll("a"); if (isSplitted && container2) { links = [...links, ...container2.querySelectorAll("a")]; } for (const link of links) { let id = link.pathname.match(/\/(\d+)\//); if (!id) { id = link.dataset.id; } else { id = id[1]; } ProviderGenius.getNote(id).then((note) => { notes[id] = note; link.classList.add("fetched"); }); link.onclick = (event) => { event.preventDefault(); if (!notes[id]) return; showNote(link, notes[id]); }; } }, [lyrics, lyrics2]); const lyricsEl1 = react.createElement( "div", null, react.createElement(VersionSelector, { items: versions, index: versionIndex, callback: onVersionChange }), react.createElement("div", { className: "lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active", ref: (c) => { container = c; }, dangerouslySetInnerHTML: { __html: lyrics, }, onContextMenu: (event) => { event.preventDefault(); const copylyrics = lyrics.replace(/
/g, "\n").replace(/<[^>]*>/g, ""); Spicetify.Platform.ClipboardAPI.copy(copylyrics) .then(() => Spicetify.showNotification("Lyrics copied to clipboard")) .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard")); }, }) ); const mainContainer = [lyricsEl1]; const shouldSplit = versions.length > 1 && isSplitted; if (shouldSplit) { const lyricsEl2 = react.createElement( "div", null, react.createElement(VersionSelector, { items: versions, index: versionIndex2, callback: onVersionChange2 }), react.createElement("div", { className: "lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active", ref: (c) => { container2 = c; }, dangerouslySetInnerHTML: { __html: lyrics2, }, onContextMenu: (event) => { event.preventDefault(); const copylyrics = lyrics.replace(/
/g, "\n").replace(/<[^>]*>/g, ""); Spicetify.Platform.ClipboardAPI.copy(copylyrics) .then(() => Spicetify.showNotification("Lyrics copied to clipboard")) .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard")); }, }) ); mainContainer.push(lyricsEl2); } return react.createElement( "div", { className: "lyrics-lyricsContainer-UnsyncedLyricsPage", }, react.createElement("p", { className: "lyrics-lyricsContainer-LyricsUnsyncedPadding main-type-ballad", }), react.createElement("div", { className: shouldSplit ? "split" : "" }, mainContainer), react.createElement(CreditFooter, { provider, copyright, }), react.createElement(SearchBar, null) ); } ); const LoadingIcon = react.createElement( "svg", { width: "200px", height: "200px", viewBox: "0 0 100 100", preserveAspectRatio: "xMidYMid", }, react.createElement( "circle", { cx: "50", cy: "50", r: "0", fill: "none", stroke: "currentColor", "stroke-width": "2", }, react.createElement("animate", { attributeName: "r", repeatCount: "indefinite", dur: "1s", values: "0;40", keyTimes: "0;1", keySplines: "0 0.2 0.8 1", calcMode: "spline", begin: "0s", }), react.createElement("animate", { attributeName: "opacity", repeatCount: "indefinite", dur: "1s", values: "1;0", keyTimes: "0;1", keySplines: "0.2 0 0.8 1", calcMode: "spline", begin: "0s", }) ), react.createElement( "circle", { cx: "50", cy: "50", r: "0", fill: "none", stroke: "currentColor", "stroke-width": "2", }, react.createElement("animate", { attributeName: "r", repeatCount: "indefinite", dur: "1s", values: "0;40", keyTimes: "0;1", keySplines: "0 0.2 0.8 1", calcMode: "spline", begin: "-0.5s", }), react.createElement("animate", { attributeName: "opacity", repeatCount: "indefinite", dur: "1s", values: "1;0", keyTimes: "0;1", keySplines: "0.2 0 0.8 1", calcMode: "spline", begin: "-0.5s", }) ) ); const VersionSelector = react.memo(({ items, index, callback }) => { if (items.length < 2) { return null; } return react.createElement( "div", { className: "lyrics-versionSelector", }, react.createElement( "select", { onChange: (event) => { callback(items, event.target.value); }, value: index, }, items.map((a, i) => { return react.createElement("option", { value: i }, a.title); }) ), react.createElement( "svg", { height: "16", width: "16", fill: "currentColor", viewBox: "0 0 16 16", }, react.createElement("path", { d: "M3 6l5 5.794L13 6z", }) ) ); }); ================================================ FILE: CustomApps/lyrics-plus/PlaybarButton.js ================================================ (function PlaybarButton() { if (!Spicetify.Platform.History) { setTimeout(PlaybarButton, 300); return; } const button = new Spicetify.Playbar.Button( "Lyrics Plus", ``, () => Spicetify.Platform.History.location.pathname !== "/lyrics-plus" ? Spicetify.Platform.History.push("/lyrics-plus") : Spicetify.Platform.History.goBack(), false, Spicetify.Platform.History.location.pathname === "/lyrics-plus", false ); const style = document.createElement("style"); style.innerHTML = ` .main-nowPlayingBar-lyricsButton[data-testid="lyrics-button"] { display: none !important; } li[data-id="/lyrics-plus"] { display: none; } `; style.classList.add("lyrics-plus:visual:playbar-button"); if (Spicetify.LocalStorage.get("lyrics-plus:visual:playbar-button") === "true") setPlaybarButton(); window.addEventListener("lyrics-plus", (event) => { if (event.detail?.name === "playbar-button") event.detail.value ? setPlaybarButton() : removePlaybarButton(); }); Spicetify.Platform.History.listen((location) => { button.active = location.pathname === "/lyrics-plus"; }); function setPlaybarButton() { document.head.appendChild(style); button.register(); } function removePlaybarButton() { style.remove(); button.deregister(); } })(); ================================================ FILE: CustomApps/lyrics-plus/ProviderGenius.js ================================================ const ProviderGenius = (() => { function getChildDeep(parent, isDeep = false) { let acc = ""; if (!parent.children) { return acc; } for (const child of parent.children) { if (typeof child === "string") { acc += child; } else if (child.children) { acc += getChildDeep(child, true); } if (!isDeep) { acc += "\n"; } } return acc.trim(); } async function getNote(id) { const body = await Spicetify.CosmosAsync.get(`https://genius.com/api/annotations/${id}`); const response = body.response; let note = ""; // Authors annotations if (response.referent && response.referent.classification === "verified") { const referentsBody = await Spicetify.CosmosAsync.get(`https://genius.com/api/referents/${id}`); const referents = referentsBody.response; for (const ref of referents.referent.annotations) { note += getChildDeep(ref.body.dom); } } // Users annotations if (!note && response.annotation) { note = getChildDeep(response.annotation.body.dom); } // Users comments if (!note && response.annotation && response.annotation.top_comment) { note += getChildDeep(response.annotation.top_comment.body.dom); } note = note.replace(/\n\n\n?/, "\n"); return note; } function fetchHTML(url) { return new Promise((resolve, reject) => { const request = JSON.stringify({ method: "GET", uri: url, }); window.sendCosmosRequest({ request, persistent: false, onSuccess: resolve, onFailure: reject, }); }); } async function fetchLyricsVersion(results, index) { const result = results[index]; if (!result) { console.warn(result); return; } const site = await fetchHTML(result.url); const body = JSON.parse(site)?.body; if (!body) { return null; } let lyrics = ""; const parser = new DOMParser(); const htmlDoc = parser.parseFromString(body, "text/html"); const lyricsDiv = htmlDoc.querySelectorAll('div[data-lyrics-container="true"]'); for (const i of lyricsDiv) { lyrics += `${i.innerHTML}
`; } if (!lyrics?.length) { console.warn("forceError"); return null; } return lyrics; } async function fetchLyrics(info) { const titles = new Set([info.title]); const titleNoExtra = Utils.removeExtraInfo(info.title); titles.add(titleNoExtra); titles.add(Utils.removeSongFeat(info.title)); titles.add(Utils.removeSongFeat(titleNoExtra)); let lyrics; let hits; for (const title of titles) { const query = new URLSearchParams({ per_page: 20, q: `${info.artist} ${title}` }); const url = `https://genius.com/api/search/song?${query.toString()}`; const geniusSearch = await Spicetify.CosmosAsync.get(url); hits = geniusSearch.response.sections[0].hits.map((item) => ({ title: item.result.full_title, url: item.result.url, })); if (!hits.length) { continue; } lyrics = await fetchLyricsVersion(hits, 0); break; } if (!lyrics) { return { lyrics: null, versions: [] }; } return { lyrics, versions: hits }; } return { fetchLyrics, getNote, fetchLyricsVersion }; })(); ================================================ FILE: CustomApps/lyrics-plus/ProviderLRCLIB.js ================================================ const ProviderLRCLIB = (() => { async function findLyrics(info) { const baseURL = "https://lrclib.net/api/get"; const durr = info.duration / 1000; const params = { track_name: info.title, artist_name: info.artist, album_name: info.album, duration: durr, }; const finalURL = `${baseURL}?${Object.keys(params) .map((key) => `${key}=${encodeURIComponent(params[key])}`) .join("&")}`; const body = await fetch(finalURL, { headers: { "x-user-agent": `spicetify v${Spicetify.Config.version} (https://github.com/spicetify/cli)`, }, }); if (body.status !== 200) { return { error: "Request error: Track wasn't found", uri: info.uri, }; } return await body.json(); } function getUnsynced(body) { const unsyncedLyrics = body?.plainLyrics; const isInstrumental = body.instrumental; if (isInstrumental) return [{ text: "♪ Instrumental ♪" }]; if (!unsyncedLyrics) return null; return Utils.parseLocalLyrics(unsyncedLyrics).unsynced; } function getSynced(body) { const syncedLyrics = body?.syncedLyrics; const isInstrumental = body.instrumental; if (isInstrumental) return [{ text: "♪ Instrumental ♪" }]; if (!syncedLyrics) return null; return Utils.parseLocalLyrics(syncedLyrics).synced; } return { findLyrics, getSynced, getUnsynced }; })(); ================================================ FILE: CustomApps/lyrics-plus/ProviderMusixmatch.js ================================================ const ProviderMusixmatch = (() => { const headers = { authority: "apic-desktop.musixmatch.com", cookie: "x-mxm-token-guid=", }; function findTranslationStatus(body) { if (!body || typeof body !== "object") { return null; } if (Array.isArray(body)) { for (const item of body) { const result = findTranslationStatus(item); if (result) { return result; } } return null; } if (Array.isArray(body.track_lyrics_translation_status)) { return body.track_lyrics_translation_status; } for (const value of Object.values(body)) { const result = findTranslationStatus(value); if (result) { return result; } } return null; } async function findLyrics(info) { const baseURL = "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&"; const durr = info.duration / 1000; const params = { q_album: info.album, q_artist: info.artist, q_artists: info.artist, q_track: info.title, track_spotify_id: info.uri, q_duration: durr, f_subtitle_length: Math.floor(durr), usertoken: CONFIG.providers.musixmatch.token, part: "track_lyrics_translation_status,track_structure,track_performer_tagging", }; const finalURL = baseURL + Object.keys(params) .map((key) => `${key}=${encodeURIComponent(params[key])}`) .join("&"); let body = await Spicetify.CosmosAsync.get(finalURL, null, headers); body = body.message.body.macro_calls; if (body["matcher.track.get"].message.header.status_code !== 200) { return { error: `Requested error: ${body["matcher.track.get"].message.header.mode}`, uri: info.uri, }; } if (body["track.lyrics.get"]?.message?.body?.lyrics?.restricted) { return { error: "Unfortunately we're not authorized to show these lyrics.", uri: info.uri, }; } const translationStatus = findTranslationStatus(body); const meta = body?.["matcher.track.get"]?.message?.body; const availableTranslations = Array.isArray(translationStatus) ? [...new Set(translationStatus.map((status) => status?.to).filter(Boolean))] : []; Object.defineProperties(body, { __musixmatchTranslationStatus: { value: availableTranslations, }, __musixmatchTrackId: { value: meta?.track?.track_id ?? null, }, }); return body; } function parsePerformerData(meta) { if (!meta || !meta.track || !meta.track.performer_tagging) { return []; } const tagging = meta.track.performer_tagging; const miscTags = meta.track.performer_tagging_misc_tags || {}; let performerMap = []; if (tagging && tagging.content && tagging.content.length > 0) { const resources = tagging.resources?.artists || []; const resourcesList = Array.isArray(resources) ? resources : Object.values(resources); performerMap = tagging.content .map((c) => { if (!c.performers || c.performers.length === 0) return null; const resolvedPerformers = c.performers .map((p) => { let name = "Unknown"; if (p.type === "artist") { const fqid = p.fqid; const idFromFqid = fqid ? parseInt(fqid.split(":")[2]) : null; const artist = resourcesList.find((r) => r.artist_id === idFromFqid); if (artist) name = artist.artist_name; } else if (miscTags[p.type]) { name = miscTags[p.type]; } return { fqid: p.fqid, artist_id: p.fqid ? parseInt(p.fqid.split(":")[2]) : null, name: name, }; }) .filter((p) => p.name !== "Unknown"); const names = resolvedPerformers.map((p) => p.name); if (names.length === 0) return null; return { name: names.join(", "), snippet: c.snippet, performers: resolvedPerformers, }; }) .filter(Boolean); } const normalizeForMatch = (text) => text.replace(/\s+/g, "").toLowerCase(); const snippetQueue = []; if (performerMap.length > 0) { for (const tag of performerMap) { if (!tag.snippet) continue; const snippetLines = tag.snippet .split(/\n+/) .map((s) => s.trim()) .filter(Boolean); for (const sLine of snippetLines) { if (sLine.length < 2 && !/^[\u3131-\uD79D]/.test(sLine)) continue; snippetQueue.push({ text: normalizeForMatch(sLine), raw: sLine, performers: tag.performers, }); } } } return snippetQueue; } function matchSequential(lyricsLines, snippetQueue, getTextCallback = (l) => l.text) { if (!snippetQueue || snippetQueue.length === 0) return lyricsLines; const normalizeForMatch = (text) => text.replace(/\s+/g, "").toLowerCase(); let queueCursor = 0; const LOOKAHEAD = 5; return lyricsLines.map((line) => { const lineText = getTextCallback(line) || "♪"; let normalizedLine = normalizeForMatch(lineText); let matchedPerformers = []; while (queueCursor < snippetQueue.length) { let matchFoundAtOffset = -1; for (let i = 0; i < LOOKAHEAD && queueCursor + i < snippetQueue.length; i++) { const snippet = snippetQueue[queueCursor + i]; if (normalizedLine.includes(snippet.text) && snippet.text.length > 0) { matchFoundAtOffset = i; break; } } if (matchFoundAtOffset !== -1) { queueCursor += matchFoundAtOffset; const matchedSnippet = snippetQueue[queueCursor]; matchedPerformers.push(...matchedSnippet.performers); normalizedLine = normalizedLine.replace(matchedSnippet.text, ""); queueCursor++; } else { break; } } const uniquePerformers = []; const sawMap = new Set(); for (const p of matchedPerformers) { const key = p.fqid || p.name; if (!sawMap.has(key)) { sawMap.add(key); uniquePerformers.push(p); } } return { ...line, performers: uniquePerformers, }; }); } async function getKaraoke(body) { const meta = body?.["matcher.track.get"]?.message?.body; if (!meta) { return null; } if (!meta.track.has_richsync || meta.track.instrumental) { return null; } 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&"; const params = { f_subtitle_length: meta.track.track_length, q_duration: meta.track.track_length, commontrack_id: meta.track.commontrack_id, usertoken: CONFIG.providers.musixmatch.token, }; const finalURL = baseURL + Object.keys(params) .map((key) => `${key}=${encodeURIComponent(params[key])}`) .join("&"); let result = await Spicetify.CosmosAsync.get(finalURL, null, headers); if (result.message.header.status_code !== 200) { return null; } result = result.message.body; const snippetQueue = parsePerformerData(meta); const parsedKaraoke = JSON.parse(result.richsync.richsync_body).map((line) => { const startTime = line.ts * 1000; const endTime = line.te * 1000; const words = line.l; const text = words.map((word, index, words) => { const wordText = word.c; const wordStartTime = word.o * 1000; const nextWordStartTime = words[index + 1]?.o * 1000; const time = !Number.isNaN(nextWordStartTime) ? nextWordStartTime - wordStartTime : endTime - (wordStartTime + startTime); return { word: wordText, time, }; }); return { startTime, endTime, text, }; }); return matchSequential(parsedKaraoke, snippetQueue, (line) => { if (Array.isArray(line.text)) { return line.text.map((t) => t.word).join(""); } return line.text; }).map((line) => { const performerNames = (line.performers || []) .map((p) => p.name) .filter(Boolean) .join(", "); return { ...line, performer: performerNames || null, }; }); } function getSynced(body) { const meta = body?.["matcher.track.get"]?.message?.body; if (!meta) { return null; } const hasSynced = meta?.track?.has_subtitles; const isInstrumental = meta?.track?.instrumental; if (isInstrumental) { return [{ text: "♪ Instrumental ♪", startTime: "0000" }]; } if (hasSynced) { const subtitle = body["track.subtitles.get"]?.message?.body?.subtitle_list?.[0]?.subtitle; if (!subtitle) { return null; } const snippetQueue = parsePerformerData(meta); const rawLines = JSON.parse(subtitle.subtitle_body); return matchSequential(rawLines, snippetQueue, (l) => l.text).map((line) => { const lineText = line.text || "♪"; const performerNames = (line.performers || []) .map((p) => p.name) .filter(Boolean) .join(", "); return { text: lineText, startTime: line.time.total * 1000, performer: performerNames || null, }; }); } return null; } function getUnsynced(body) { const meta = body?.["matcher.track.get"]?.message?.body; if (!meta) { return null; } const hasUnSynced = meta.track.has_lyrics || meta.track.has_lyrics_crowd; const isInstrumental = meta?.track?.instrumental; if (isInstrumental) { return [{ text: "♪ Instrumental ♪" }]; } if (hasUnSynced) { const lyrics = body["track.lyrics.get"]?.message?.body?.lyrics?.lyrics_body; if (!lyrics) { return null; } const snippetQueue = parsePerformerData(meta); const rawLines = lyrics.split("\n").map((text) => ({ text })); return matchSequential(rawLines, snippetQueue, (l) => l.text).map((line) => { const performerNames = (line.performers || []) .map((p) => p.name) .filter(Boolean) .join(", "); return { ...line, performer: performerNames || null, }; }); } return null; } async function getTranslation(trackId) { if (!trackId) return null; const selectedLanguage = CONFIG.visual["musixmatch-translation-language"] || "none"; if (selectedLanguage === "none") return null; const baseURL = "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&"; const params = { track_id: trackId, selected_language: selectedLanguage, usertoken: CONFIG.providers.musixmatch.token, }; const finalURL = baseURL + Object.keys(params) .map((key) => `${key}=${encodeURIComponent(params[key])}`) .join("&"); let result = await Spicetify.CosmosAsync.get(finalURL, null, headers); if (result.message.header.status_code !== 200) return null; result = result.message.body; if (!result.translations_list?.length) return null; return result.translations_list.map(({ translation }) => ({ translation: translation.description, matchedLine: translation.matched_line, })); } let languageMap = null; async function getLanguages() { if (languageMap) return languageMap; try { const cached = localStorage.getItem("lyrics-plus:musixmatch-languages"); if (cached) { const tempMap = JSON.parse(cached); // Check cache version if (tempMap.__version === 1) { delete tempMap.__version; languageMap = tempMap; return languageMap; } } } catch (e) { console.warn("Failed to parse cached languages", e); } const baseURL = "https://apic-desktop.musixmatch.com/ws/1.1/languages.get?app_id=web-desktop-app-v1.0&get_romanized_info=1&"; const params = { usertoken: CONFIG.providers.musixmatch.token, }; const finalURL = baseURL + Object.keys(params) .map((key) => `${key}=${encodeURIComponent(params[key])}`) .join("&"); try { let body = await Spicetify.CosmosAsync.get(finalURL, null, headers); if (body?.message?.body?.language_list) { languageMap = {}; body.message.body.language_list.forEach((item) => { const lang = item.language; if (lang.language_name) { const name = lang.language_name.charAt(0).toUpperCase() + lang.language_name.slice(1); if (lang.language_iso_code_1) languageMap[lang.language_iso_code_1] = name; if (lang.language_iso_code_3) languageMap[lang.language_iso_code_3] = name; } }); localStorage.setItem("lyrics-plus:musixmatch-languages", JSON.stringify({ ...languageMap, __version: 1 })); return languageMap; } } catch (e) { console.error("Failed to fetch languages", e); } return {}; } return { findLyrics, getKaraoke, getSynced, getUnsynced, getTranslation, getLanguages }; })(); ================================================ FILE: CustomApps/lyrics-plus/ProviderNetease.js ================================================ const ProviderNetease = (() => { const requestHeader = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0", }; async function findLyrics(info) { const searchURL = "https://music.xianqiao.wang/neteaseapiv2/search?limit=10&type=1&keywords="; const lyricURL = "https://music.xianqiao.wang/neteaseapiv2/lyric?id="; const cleanTitle = Utils.removeExtraInfo(Utils.removeSongFeat(Utils.normalize(info.title))); const finalURL = searchURL + encodeURIComponent(`${cleanTitle} ${info.artist}`); const searchResults = await Spicetify.CosmosAsync.get(finalURL, null, requestHeader); const items = searchResults.result.songs; if (!items?.length) { throw "Cannot find track"; } // normalized expected album name const neAlbumName = Utils.normalize(info.album); const expectedAlbumName = Utils.containsHanCharacter(neAlbumName) ? await Utils.toSimplifiedChinese(neAlbumName) : neAlbumName; let itemId = items.findIndex((val) => Utils.normalize(val.album.name) === expectedAlbumName); if (itemId === -1) itemId = items.findIndex((val) => Math.abs(info.duration - val.duration) < 3000); if (itemId === -1) itemId = items.findIndex((val) => val.name === cleanTitle); if (itemId === -1) throw "Cannot find track"; return await Spicetify.CosmosAsync.get(lyricURL + items[itemId].id, null, requestHeader); } const creditInfo = [ "\\s?作?\\s*词|\\s?作?\\s*曲|\\s?编\\s*曲?|\\s?监\\s*制?", ".*编写|.*和音|.*和声|.*合声|.*提琴|.*录|.*工程|.*工作室|.*设计|.*剪辑|.*制作|.*发行|.*出品|.*后期|.*混音|.*缩混", "原唱|翻唱|题字|文案|海报|古筝|二胡|钢琴|吉他|贝斯|笛子|鼓|弦乐", "lrc|publish|vocal|guitar|program|produce|write|mix", ]; const creditInfoRegExp = new RegExp(`^(${creditInfo.join("|")}).*(:|:)`, "i"); function containCredits(text) { return creditInfoRegExp.test(text); } function parseTimestamp(line) { // ["[ar:Beyond]"] // ["[03:10]"] // ["[03:10]", "lyrics"] // ["lyrics"] // ["[03:10]", "[03:10]", "lyrics"] // ["[1235,300]", "lyrics"] const matchResult = line.match(/(\[.*?\])|([^[\]]+)/g); if (!matchResult?.length || matchResult.length === 1) { return { text: line }; } const textIndex = matchResult.findIndex((slice) => !slice.endsWith("]")); let text = ""; if (textIndex > -1) { text = matchResult.splice(textIndex, 1)[0]; text = Utils.capitalize(Utils.normalize(text, false)); } const time = matchResult[0].replace("[", "").replace("]", ""); return { time, text }; } function breakdownLine(text) { // (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) const components = text.split(/\(\d+,(\d+)\)/g); // ["", "508", "Don't", "1", " ", "151", "want" , "1" ...] const result = []; for (let i = 1; i < components.length; i += 2) { if (components[i + 1] === " ") continue; result.push({ word: `${components[i + 1]} `, time: Number.parseInt(components[i]), }); } return result; } function getKaraoke(list) { const lyricStr = list?.klyric?.lyric; if (!lyricStr) { return null; } const lines = lyricStr.split(/\r?\n/).map((line) => line.trim()); const karaoke = lines .map((line) => { const { time, text } = parseTimestamp(line); if (!time || !text) return null; const [key, value] = time.split(",") || []; const [start, durr] = [Number.parseFloat(key), Number.parseFloat(value)]; if (!Number.isNaN(start) && !Number.isNaN(durr) && !containCredits(text)) { return { startTime: start, // endTime: start + durr, text: breakdownLine(text), }; } return null; }) .filter(Boolean); if (!karaoke.length) { return null; } return karaoke; } function getSynced(list) { const lyricStr = list?.lrc?.lyric; let noLyrics = false; if (!lyricStr) { return null; } const lines = lyricStr.split(/\r?\n/).map((line) => line.trim()); const lyrics = lines .map((line) => { const { time, text } = parseTimestamp(line); if (text === "纯音乐, 请欣赏") noLyrics = true; if (!time || !text) return null; const [key, value] = time.split(":") || []; const [min, sec] = [Number.parseFloat(key), Number.parseFloat(value)]; if (!Number.isNaN(min) && !Number.isNaN(sec) && !containCredits(text)) { return { startTime: (min * 60 + sec) * 1000, text: text || "", }; } return null; }) .filter(Boolean); if (!lyrics.length || noLyrics) { return null; } return lyrics; } function getTranslation(list) { const lyricStr = list?.tlyric?.lyric; if (!lyricStr) { return null; } const lines = lyricStr.split(/\r?\n/).map((line) => line.trim()); const translation = lines .map((line) => { const { time, text } = parseTimestamp(line); if (!time || !text) return null; const [key, value] = time.split(":") || []; const [min, sec] = [Number.parseFloat(key), Number.parseFloat(value)]; if (!Number.isNaN(min) && !Number.isNaN(sec) && !containCredits(text)) { return { startTime: (min * 60 + sec) * 1000, text: text || "", }; } return null; }) .filter(Boolean); if (!translation.length) { return null; } return translation; } function getUnsynced(list) { const lyricStr = list?.lrc?.lyric; let noLyrics = false; if (!lyricStr) { return null; } const lines = lyricStr.split(/\r?\n/).map((line) => line.trim()); const lyrics = lines .map((line) => { const parsed = parseTimestamp(line); if (parsed.text === "纯音乐, 请欣赏") noLyrics = true; if (!parsed.text || containCredits(parsed.text)) return null; return parsed; }) .filter(Boolean); if (!lyrics.length || noLyrics) { return null; } return lyrics; } return { findLyrics, getKaraoke, getSynced, getUnsynced, getTranslation }; })(); ================================================ FILE: CustomApps/lyrics-plus/Providers.js ================================================ const Providers = { spotify: async (info) => { const result = { uri: info.uri, karaoke: null, synced: null, unsynced: null, provider: "Spotify", copyright: null, }; const baseURL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"; const id = info.uri.split(":")[2]; let body; try { body = await Spicetify.CosmosAsync.get(`${baseURL + id}?format=json&vocalRemoval=false&market=from_token`); } catch { return { error: "Request error", uri: info.uri }; } const lyrics = body.lyrics; if (!lyrics) { return { error: "No lyrics", uri: info.uri }; } const lines = lyrics.lines; if (lyrics.syncType === "LINE_SYNCED") { result.synced = lines.map((line) => ({ startTime: line.startTimeMs, text: line.words, })); result.unsynced = result.synced; } else { result.unsynced = lines.map((line) => ({ text: line.words, })); } /** * to distinguish it from the existing Musixmatch, the provider will remain as Spotify. * if Spotify official lyrics support multiple providers besides Musixmatch in the future, please uncomment the under section. */ // result.provider = lyrics.provider; return result; }, musixmatch: async (info) => { const result = { error: null, uri: info.uri, karaoke: null, synced: null, unsynced: null, musixmatchTranslation: null, musixmatchAvailableTranslations: [], musixmatchTrackId: null, musixmatchTranslationLanguage: null, provider: "Musixmatch", copyright: null, }; let list; try { list = await ProviderMusixmatch.findLyrics(info); if (list.error) { throw ""; } } catch { result.error = "No lyrics"; return result; } const karaoke = await ProviderMusixmatch.getKaraoke(list); if (karaoke) { result.karaoke = karaoke; result.copyright = list["track.lyrics.get"].message?.body?.lyrics?.lyrics_copyright?.trim(); } const synced = ProviderMusixmatch.getSynced(list); if (synced) { result.synced = synced; result.copyright = list["track.subtitles.get"].message?.body?.subtitle_list?.[0]?.subtitle.lyrics_copyright.trim(); } const unsynced = synced || ProviderMusixmatch.getUnsynced(list); if (unsynced) { result.unsynced = unsynced; result.copyright = list["track.lyrics.get"].message?.body?.lyrics?.lyrics_copyright?.trim(); } result.musixmatchAvailableTranslations = Array.isArray(list.__musixmatchTranslationStatus) ? list.__musixmatchTranslationStatus : []; result.musixmatchTrackId = list.__musixmatchTrackId ?? null; const selectedLanguage = CONFIG.visual["musixmatch-translation-language"]; const canRequestTranslation = selectedLanguage && selectedLanguage !== "none" && result.musixmatchAvailableTranslations.includes(selectedLanguage); const translation = canRequestTranslation ? await ProviderMusixmatch.getTranslation(result.musixmatchTrackId) : null; if ((synced || unsynced) && Array.isArray(translation) && translation.length) { const normalizeLyrics = typeof Utils !== "undefined" && typeof Utils.processLyrics === "function" ? (value) => Utils.processLyrics(value ?? "") : (value) => typeof value === "string" ? value.replace(/ | /g, "").replace(/[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~?!,。、《》【】「」]/g, "") : ""; const translationMap = new Map(); for (const entry of translation) { const normalizedMatched = normalizeLyrics(entry.matchedLine); if (!translationMap.has(normalizedMatched)) { translationMap.set(normalizedMatched, entry.translation); } } const baseLyrics = synced ?? unsynced; result.musixmatchTranslation = baseLyrics.map((line) => { const originalText = line.text; const normalizedOriginal = normalizeLyrics(originalText); return { ...line, text: translationMap.get(normalizedOriginal) ?? line.text, originalText, }; }); result.musixmatchTranslationLanguage = selectedLanguage; } return result; }, netease: async (info) => { const result = { uri: info.uri, karaoke: null, synced: null, unsynced: null, neteaseTranslation: null, provider: "Netease", copyright: null, }; let list; try { list = await ProviderNetease.findLyrics(info); } catch { result.error = "No lyrics"; return result; } const karaoke = ProviderNetease.getKaraoke(list); if (karaoke) { result.karaoke = karaoke; } const synced = ProviderNetease.getSynced(list); if (synced) { result.synced = synced; } const unsynced = synced || ProviderNetease.getUnsynced(list); if (unsynced) { result.unsynced = unsynced; } const translation = ProviderNetease.getTranslation(list); if ((synced || unsynced) && Array.isArray(translation)) { const baseLyrics = synced ?? unsynced; result.neteaseTranslation = baseLyrics.map((line) => ({ ...line, text: translation.find((t) => t.startTime === line.startTime)?.text ?? line.text, originalText: line.text, })); } return result; }, lrclib: async (info) => { const result = { uri: info.uri, karaoke: null, synced: null, unsynced: null, provider: "lrclib", copyright: null, }; let list; try { list = await ProviderLRCLIB.findLyrics(info); } catch { result.error = "No lyrics"; return result; } const synced = ProviderLRCLIB.getSynced(list); if (synced) { result.synced = synced; } const unsynced = synced || ProviderLRCLIB.getUnsynced(list); if (unsynced) { result.unsynced = unsynced; } return result; }, genius: async (info) => { const { lyrics, versions } = await ProviderGenius.fetchLyrics(info); let versionIndex2 = 0; let genius2 = lyrics; if (CONFIG.visual["dual-genius"] && versions.length > 1) { genius2 = await ProviderGenius.fetchLyricsVersion(versions, 1); versionIndex2 = 1; } return { uri: info.uri, genius: lyrics, provider: "Genius", karaoke: null, synced: null, unsynced: null, copyright: null, error: null, versions, versionIndex: 0, genius2, versionIndex2, }; }, local: (info) => { let result = { uri: info.uri, karaoke: null, synced: null, unsynced: null, provider: "local", }; try { const savedLyrics = JSON.parse(localStorage.getItem("lyrics-plus:local-lyrics")); const lyrics = savedLyrics[info.uri]; if (!lyrics) { throw ""; } result = { ...result, ...lyrics, }; } catch { result.error = "No lyrics"; } return result; }, }; ================================================ FILE: CustomApps/lyrics-plus/README.md ================================================ # Spicetify Custom App ### Lyrics Plus Show current track lyrics. Current lyrics providers: - Internal Spotify lyrics service. - Netease: From Chinese developers and users. Provides karaoke and synced lyrics. - Musixmatch: A company from Italy. Provided synced lyrics. - 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). ![kara](./kara.png) ![genius](./genius.png) 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. 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 ![lockin](./lockin.png) 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. ![search](./search.png) Choose between different option of displaying Japanese lyrics. (Furigana, Romaji, Hiragana, Katakana) ![conversion](./conversion.png) Customise colors, change providers' priorities in config menu. Config menu locates in Profile Menu (top right button with your user name). To install, run: ```bash spicetify config custom_apps lyrics-plus spicetify apply ``` ### Credits - 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. - 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. - The algorithm for converting Japanese lyrics is based on [Hexenq's Kuroshiro](https://github.com/hexenq/kuroshiro). - 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). - The algorithm for converting Korean lyrics is based on [fujaru's aromanize-js](https://github.com/fujaru/aromanize-js) - The algorithm for detecting Simplified Chinese is adapted from [nickdrewe's traditional-or-simplified](https://github.com/nickdrewe/traditional-or-simplified). ================================================ FILE: CustomApps/lyrics-plus/Settings.js ================================================ const ButtonSVG = ({ icon, active = true, onClick }) => { return react.createElement( "button", { className: `switch${active ? "" : " disabled"}`, onClick, }, react.createElement("svg", { width: 16, height: 16, viewBox: "0 0 16 16", fill: "currentColor", dangerouslySetInnerHTML: { __html: icon, }, }) ); }; const SwapButton = ({ icon, disabled, onClick }) => { return react.createElement( "button", { className: "switch small", onClick, disabled, }, react.createElement("svg", { width: 10, height: 10, viewBox: "0 0 16 16", fill: "currentColor", dangerouslySetInnerHTML: { __html: icon, }, }) ); }; const CacheButton = () => { let lyrics = {}; try { const localLyrics = JSON.parse(localStorage.getItem("lyrics-plus:local-lyrics")); if (!localLyrics || typeof localLyrics !== "object") { throw ""; } lyrics = localLyrics; } catch { lyrics = {}; } const [count, setCount] = useState(Object.keys(lyrics).length); const text = count ? "Clear all cached lyrics" : "No cached lyrics"; return react.createElement( "button", { className: "btn", onClick: () => { localStorage.removeItem("lyrics-plus:local-lyrics"); setCount(0); }, disabled: !count, }, text ); }; const RefreshTokenButton = ({ setTokenCallback }) => { const [buttonText, setButtonText] = useState("Refresh token"); useEffect(() => { if (buttonText === "Refreshing token...") { Spicetify.CosmosAsync.get("https://apic-desktop.musixmatch.com/ws/1.1/token.get?app_id=web-desktop-app-v1.0", null, { authority: "apic-desktop.musixmatch.com", }) .then(({ message: response }) => { if (response.header.status_code === 200 && response.body.user_token) { setTokenCallback(response.body.user_token); setButtonText("Token refreshed"); } else if (response.header.status_code === 401) { setButtonText("Too many attempts"); } else { setButtonText("Failed to refresh token"); console.error("Failed to refresh token", response); } }) .catch((error) => { setButtonText("Failed to refresh token"); console.error("Failed to refresh token", error); }); } }, [buttonText]); return react.createElement( "button", { className: "btn", onClick: () => { setButtonText("Refreshing token..."); }, disabled: buttonText !== "Refresh token", }, buttonText ); }; const ConfigButton = ({ name, text, onChange = () => {} }) => { return react.createElement( "div", { className: "setting-row", }, react.createElement( "label", { className: "col description", }, name ), react.createElement( "div", { className: "col action", }, react.createElement( "button", { className: "btn", onClick: onChange, }, text ) ) ); }; const ConfigSlider = ({ name, defaultValue, onChange = () => {} }) => { const [active, setActive] = useState(defaultValue); useEffect(() => { setActive(defaultValue); }, [defaultValue]); const toggleState = useCallback(() => { const state = !active; setActive(state); onChange(state); }, [active]); return react.createElement( "div", { className: "setting-row", }, react.createElement( "label", { className: "col description", }, name ), react.createElement( "div", { className: "col action", }, react.createElement(ButtonSVG, { icon: Spicetify.SVGIcons.check, active, onClick: toggleState, }) ) ); }; const ConfigSelection = ({ name, defaultValue, options, onChange = () => {} }) => { const [value, setValue] = useState(defaultValue); const setValueCallback = useCallback( (event) => { let value = event.target.value; if (!Number.isNaN(Number(value))) { value = Number.parseInt(value); } setValue(value); onChange(value); }, [value, options] ); useEffect(() => { setValue(defaultValue); }, [defaultValue]); if (!Object.keys(options).length) return null; return react.createElement( "div", { className: "setting-row", }, react.createElement( "label", { className: "col description", }, name ), react.createElement( "div", { className: "col action", }, react.createElement( "select", { className: "main-dropDown-dropDown", value, onChange: setValueCallback, }, Object.keys(options).map((item) => react.createElement( "option", { value: item, }, options[item] ) ) ) ) ); }; const ConfigInput = ({ name, defaultValue, onChange = () => {} }) => { const [value, setValue] = useState(defaultValue); const setValueCallback = useCallback( (event) => { const value = event.target.value; setValue(value); onChange(value); }, [value] ); return react.createElement( "div", { className: "setting-row", }, react.createElement( "label", { className: "col description", }, name ), react.createElement( "div", { className: "col action", }, react.createElement("input", { value, onChange: setValueCallback, }) ) ); }; const ConfigAdjust = ({ name, defaultValue, step, min, max, onChange = () => {} }) => { const [value, setValue] = useState(defaultValue); function adjust(dir) { let temp = value + dir * step; if (temp < min) { temp = min; } else if (temp > max) { temp = max; } setValue(temp); onChange(temp); } return react.createElement( "div", { className: "setting-row", }, react.createElement( "label", { className: "col description", }, name ), react.createElement( "div", { className: "col action", }, react.createElement(SwapButton, { icon: ``, onClick: () => adjust(-1), disabled: value === min, }), react.createElement( "p", { className: "adjust-value", }, value ), react.createElement(SwapButton, { icon: Spicetify.SVGIcons.plus2px, onClick: () => adjust(1), disabled: value === max, }) ) ); }; const ConfigHotkey = ({ name, defaultValue, onChange = () => {} }) => { const [value, setValue] = useState(defaultValue); const [trap] = useState(new Spicetify.Mousetrap()); function record() { trap.handleKey = (character, modifiers, e) => { if (e.type === "keydown") { const sequence = [...new Set([...modifiers, character])]; if (sequence.length === 1 && sequence[0] === "esc") { onChange(""); setValue(""); return; } setValue(sequence.join("+")); } }; } function finishRecord() { trap.handleKey = () => {}; onChange(value); } return react.createElement( "div", { className: "setting-row", }, react.createElement( "label", { className: "col description", }, name ), react.createElement( "div", { className: "col action", }, react.createElement("input", { value, onFocus: record, onBlur: finishRecord, }) ) ); }; const ServiceAction = ({ item, setTokenCallback }) => { switch (item.name) { case "local": return react.createElement(CacheButton); case "musixmatch": return react.createElement(RefreshTokenButton, { setTokenCallback }); default: return null; } }; const ServiceOption = ({ item, onToggle, onSwap, isFirst = false, isLast = false, onTokenChange = null }) => { const [token, setToken] = useState(item.token); const [active, setActive] = useState(item.on); const setTokenCallback = useCallback( (token) => { setToken(token); onTokenChange(item.name, token); }, [item.token] ); const toggleActive = useCallback(() => { if (item.name === "genius" && spotifyVersion >= "1.2.31") return; const state = !active; setActive(state); onToggle(item.name, state); }, [active]); return react.createElement( "div", null, react.createElement( "div", { className: "setting-row", }, react.createElement( "h3", { className: "col description", }, item.name ), react.createElement( "div", { className: "col action", }, react.createElement(ServiceAction, { item, setTokenCallback, }), react.createElement(SwapButton, { icon: Spicetify.SVGIcons["chart-up"], onClick: () => onSwap(item.name, -1), disabled: isFirst, }), react.createElement(SwapButton, { icon: Spicetify.SVGIcons["chart-down"], onClick: () => onSwap(item.name, 1), disabled: isLast, }), react.createElement(ButtonSVG, { icon: Spicetify.SVGIcons.check, active, onClick: toggleActive, }) ) ), react.createElement("span", { dangerouslySetInnerHTML: { __html: item.desc, }, }), item.token !== undefined && react.createElement("input", { placeholder: `Place your ${item.name} token here`, value: token, onChange: (event) => setTokenCallback(event.target.value), }) ); }; const ServiceList = ({ itemsList, onListChange = () => {}, onToggle = () => {}, onTokenChange = () => {} }) => { const [items, setItems] = useState(itemsList); const maxIndex = items.length - 1; const onSwap = useCallback( (name, direction) => { const curPos = items.findIndex((val) => val === name); const newPos = curPos + direction; [items[curPos], items[newPos]] = [items[newPos], items[curPos]]; onListChange(items); setItems([...items]); }, [items] ); return items.map((key, index) => { const item = CONFIG.providers[key]; item.name = key; return react.createElement(ServiceOption, { item, key, isFirst: index === 0, isLast: index === maxIndex, onSwap, onTokenChange, onToggle, }); }); }; const corsProxyTemplate = () => { const [proxyValue, setProxyValue] = react.useState(localStorage.getItem("spicetify:corsProxyTemplate") || "https://cors-proxy.spicetify.app/{url}"); return react.createElement("input", { placeholder: "CORS Proxy Template", value: proxyValue, onChange: (event) => { const value = event.target.value; setProxyValue(value); if (value === "" || !value) return localStorage.removeItem("spicetify:corsProxyTemplate"); localStorage.setItem("spicetify:corsProxyTemplate", value); }, }); }; const OptionList = ({ type, items, onChange }) => { const [itemList, setItemList] = useState(items); const [, forceUpdate] = useState(); useEffect(() => { if (!type) return; const eventListener = (event) => { if (event.detail?.type !== type) return; setItemList(event.detail.items); }; document.addEventListener("lyrics-plus", eventListener); return () => document.removeEventListener("lyrics-plus", eventListener); }, []); return itemList.map((item) => { if (!item || (item.when && !item.when())) { return; } const onChangeItem = item.onChange || onChange; return react.createElement( "div", null, react.createElement(item.type, { ...item, name: item.desc, defaultValue: CONFIG.visual[item.key], onChange: (value) => { onChangeItem(item.key, value); forceUpdate({}); }, }), item.info && react.createElement("span", { dangerouslySetInnerHTML: { __html: item.info, }, }) ); }); }; function openConfig() { const configContainer = react.createElement( "div", { id: `${APP_NAME}-config-container`, }, react.createElement("h2", null, "Options"), react.createElement(OptionList, { items: [ { desc: "Playbar button", key: "playbar-button", info: "Replace Spotify's lyrics button with Lyrics Plus.", type: ConfigSlider, }, { desc: "Global delay", info: "Offset (in ms) across all tracks.", key: "global-delay", type: ConfigAdjust, min: -10000, max: 10000, step: 250, }, { desc: "Font size", info: "(or Ctrl + Mouse scroll in main app)", key: "font-size", type: ConfigAdjust, min: fontSizeLimit.min, max: fontSizeLimit.max, step: fontSizeLimit.step, }, { desc: "Alignment", key: "alignment", type: ConfigSelection, options: { left: "Left", center: "Center", right: "Right", }, }, { desc: "Fullscreen hotkey", key: "fullscreen-key", type: ConfigHotkey, }, { desc: "Compact synced: Lines to show before", key: "lines-before", type: ConfigSelection, options: [0, 1, 2, 3, 4], }, { desc: "Compact synced: Lines to show after", key: "lines-after", type: ConfigSelection, options: [0, 1, 2, 3, 4], }, { desc: "Compact synced: Fade-out blur", key: "fade-blur", type: ConfigSlider, }, { desc: "Noise overlay", key: "noise", type: ConfigSlider, }, { desc: "Colorful background", key: "colorful", type: ConfigSlider, }, { desc: "Background color", key: "background-color", type: ConfigInput, when: () => !CONFIG.visual.colorful, }, { desc: "Active text color", key: "active-color", type: ConfigInput, when: () => !CONFIG.visual.colorful, }, { desc: "Inactive text color", key: "inactive-color", type: ConfigInput, when: () => !CONFIG.visual.colorful, }, { desc: "Highlight text background", key: "highlight-color", type: ConfigInput, when: () => !CONFIG.visual.colorful, }, { desc: "Text convertion: Japanese Detection threshold (Advanced)", info: "Checks if whenever Kana is dominant in lyrics. If the result passes the threshold, it's most likely Japanese, and vice versa. This setting is in percentage.", key: "ja-detect-threshold", type: ConfigAdjust, min: thresholdSizeLimit.min, max: thresholdSizeLimit.max, step: thresholdSizeLimit.step, }, { desc: "Text convertion: Traditional-Simplified Detection threshold (Advanced)", info: "Checks if whenever Traditional or Simplified is dominant in lyrics. If the result passes the threshold, it's most likely Simplified, and vice versa. This setting is in percentage.", key: "hans-detect-threshold", type: ConfigAdjust, min: thresholdSizeLimit.min, max: thresholdSizeLimit.max, step: thresholdSizeLimit.step, }, { desc: "Clear Memory Cache", info: "Loaded lyrics are cached in memory for faster reloading. Press this button to clear the cached lyrics from memory without restarting Spotify.", key: "clear-memore-cache", text: "Clear memory cache", type: ConfigButton, onChange: () => { reloadLyrics?.(); }, }, ], onChange: (name, value) => { CONFIG.visual[name] = value; localStorage.setItem(`${APP_NAME}:visual:${name}`, value); lyricContainerUpdate?.(); const configChange = new CustomEvent("lyrics-plus", { detail: { type: "config", name: name, value: value, }, }); window.dispatchEvent(configChange); }, }), react.createElement("h2", null, "Providers"), react.createElement(ServiceList, { itemsList: CONFIG.providersOrder, onListChange: (list) => { CONFIG.providersOrder = list; localStorage.setItem(`${APP_NAME}:services-order`, JSON.stringify(list)); reloadLyrics?.(); }, onToggle: (name, value) => { CONFIG.providers[name].on = value; localStorage.setItem(`${APP_NAME}:provider:${name}:on`, value); reloadLyrics?.(); }, onTokenChange: (name, value) => { CONFIG.providers[name].token = value; localStorage.setItem(`${APP_NAME}:provider:${name}:token`, value); reloadLyrics?.(); }, }), react.createElement("h2", null, "CORS Proxy Template"), react.createElement("span", { dangerouslySetInnerHTML: { __html: "Use this to bypass CORS restrictions. Replace the URL with your cors proxy server of your choice. {url} will be replaced with the request URL.", }, }), react.createElement(corsProxyTemplate), react.createElement("span", { dangerouslySetInnerHTML: { __html: "Spotify will reload its webview after applying. Leave empty to restore default: https://cors-proxy.spicetify.app/{url}", }, }) ); Spicetify.PopupModal.display({ title: "Lyrics Plus", content: configContainer, isLarge: true, }); } ================================================ FILE: CustomApps/lyrics-plus/TabBar.js ================================================ class TabBarItem extends react.Component { onSelect(event) { event.preventDefault(); this.props.switchTo(this.props.item.key); } onLock(event) { event.preventDefault(); this.props.lockIn(this.props.item.key); } render() { return react.createElement( "li", { className: "lyrics-tabBar-headerItem", onClick: this.onSelect.bind(this), onDoubleClick: this.onLock.bind(this), onContextMenu: this.onLock.bind(this), }, react.createElement( "a", { "aria-current": "page", className: `lyrics-tabBar-headerItemLink ${this.props.item.active ? "lyrics-tabBar-active" : ""}`, draggable: "false", href: "", }, react.createElement( "span", { className: "main-type-mestoBold", }, this.props.item.value ) ) ); } } const TabBarMore = react.memo(({ items, switchTo, lockIn }) => { const activeItem = items.find((item) => item.active); function onLock(event) { event.preventDefault(); if (activeItem) { lockIn(activeItem.key); } } return react.createElement( "li", { className: `lyrics-tabBar-headerItem ${activeItem ? "lyrics-tabBar-active" : ""}`, onDoubleClick: onLock, onContextMenu: onLock, }, react.createElement(OptionsMenu, { options: items, onSelect: switchTo, selected: activeItem, defaultValue: "More", bold: true, }) ); }); const TopBarContent = ({ links, activeLink, lockLink, switchCallback, lockCallback }) => { const resizeHost = document.querySelector( ".Root__main-view .os-resize-observer-host, .Root__main-view .os-size-observer, .Root__main-view .main-view-container__scroll-node" ); const [windowSize, setWindowSize] = useState(resizeHost.clientWidth); const resizeHandler = () => setWindowSize(resizeHost.clientWidth); useEffect(() => { const observer = new ResizeObserver(resizeHandler); observer.observe(resizeHost); return () => { observer.disconnect(); }; }, [resizeHandler]); return react.createElement( TabBarContext, null, react.createElement(TabBar, { className: "queue-queueHistoryTopBar-tabBar", links, activeLink, lockLink, switchCallback, lockCallback, windowSize, }) ); }; const TabBarContext = ({ children }) => { return Spicetify.ReactDOM.createPortal( react.createElement( "div", { className: "main-topBar-topbarContent", }, children ), document.querySelector(".main-topBar-topbarContentWrapper") ); }; const TabBar = react.memo(({ links, activeLink, lockLink, switchCallback, lockCallback, windowSize = Number.POSITIVE_INFINITY }) => { const tabBarRef = react.useRef(null); const [childrenSizes, setChildrenSizes] = useState([]); const [availableSpace, setAvailableSpace] = useState(0); const [droplistItem, setDroplistItems] = useState([]); const options = []; for (let i = 0; i < links.length; i++) { const key = links[i]; if (spotifyVersion >= "1.2.31" && key === "genius") continue; let value = key[0].toUpperCase() + key.slice(1); if (key === lockLink) value = `• ${value}`; const active = key === activeLink; options.push({ key, value, active }); } useEffect(() => { if (!tabBarRef.current) return; setAvailableSpace(tabBarRef.current.clientWidth); }, [windowSize]); useEffect(() => { if (!tabBarRef.current) return; const tabbarItemSizes = []; for (const child of tabBarRef.current.children) { tabbarItemSizes.push(child.clientWidth); } setChildrenSizes(tabbarItemSizes); }, [links]); useEffect(() => { if (!tabBarRef.current) return; const totalSize = childrenSizes.reduce((a, b) => a + b, 0); // Can we render everything? if (totalSize <= availableSpace) { setDroplistItems([]); return; } // The `More` button can be set to _any_ of the children. So we // reserve space for the largest item instead of always taking // the last item. const viewMoreButtonSize = Math.max(...childrenSizes); // Figure out how many children we can render while also showing // the More button const itemsToHide = []; let stopWidth = viewMoreButtonSize; childrenSizes.forEach((childWidth, i) => { if (availableSpace >= stopWidth + childWidth) { stopWidth += childWidth; } else { // First elem is edit button itemsToHide.push(i); } }); setDroplistItems(itemsToHide); }, [availableSpace, childrenSizes]); return react.createElement( "nav", { className: "lyrics-tabBar lyrics-tabBar-nav", }, react.createElement( "ul", { className: "lyrics-tabBar-header", ref: tabBarRef, }, react.createElement("li", { className: "lyrics-tabBar-headerItem", }), options .filter((_, id) => !droplistItem.includes(id)) .map((item) => react.createElement(TabBarItem, { item, switchTo: switchCallback, lockIn: lockCallback, }) ), droplistItem.length || childrenSizes.length === 0 ? react.createElement(TabBarMore, { items: droplistItem.map((i) => options[i]).filter(Boolean), switchTo: switchCallback, lockIn: lockCallback, }) : null ) ); }); ================================================ FILE: CustomApps/lyrics-plus/Translator.js ================================================ const kuroshiroPath = "https://cdn.jsdelivr.net/npm/kuroshiro@1.2.0/dist/kuroshiro.min.js"; const kuromojiPath = "https://cdn.jsdelivr.net/npm/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js"; const aromanize = "https://cdn.jsdelivr.net/npm/aromanize@0.1.5/aromanize.min.js"; const openCCPath = "https://cdn.jsdelivr.net/npm/opencc-js@1.0.5/dist/umd/full.min.js"; const dictPath = "https:/cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict"; class Translator { constructor(lang, isUsingNetease = false) { this.finished = { ja: false, ko: false, zh: false, }; this.isUsingNetease = isUsingNetease; this.applyKuromojiFix(); this.injectExternals(lang); this.createTranslator(lang); } includeExternal(url) { if ((CONFIG.visual.translate || this.isUsingNetease) && !document.querySelector(`script[src="${url}"]`)) { const script = document.createElement("script"); script.setAttribute("type", "text/javascript"); script.setAttribute("src", url); document.head.appendChild(script); } } injectExternals(lang) { switch (lang?.slice(0, 2)) { case "ja": this.includeExternal(kuromojiPath); this.includeExternal(kuroshiroPath); break; case "ko": this.includeExternal(aromanize); break; case "zh": this.includeExternal(openCCPath); break; } } async awaitFinished(language) { return new Promise((resolve) => { const interval = setInterval(() => { this.injectExternals(language); this.createTranslator(language); const lan = language.slice(0, 2); if (this.finished[lan]) { clearInterval(interval); resolve(); } }, 100); }); } /** * Fix an issue with kuromoji when loading dict from external urls * Adapted from: https://github.com/mobilusoss/textlint-browser-runner/pull/7 */ applyKuromojiFix() { if (typeof XMLHttpRequest.prototype.realOpen !== "undefined") return; XMLHttpRequest.prototype.realOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url, bool) { if (url.indexOf(dictPath.replace("https://", "https:/")) === 0) { this.realOpen(method, url.replace("https:/", "https://"), bool); } else { this.realOpen(method, url, bool); } }; } async createTranslator(lang) { switch (lang.slice(0, 2)) { case "ja": if (this.kuroshiro) return; if (typeof Kuroshiro === "undefined" || typeof KuromojiAnalyzer === "undefined") { await Translator.#sleep(50); return this.createTranslator(lang); } this.kuroshiro = new Kuroshiro.default(); this.kuroshiro.init(new KuromojiAnalyzer({ dictPath })).then( function () { this.finished.ja = true; }.bind(this) ); break; case "ko": if (this.Aromanize) return; if (typeof Aromanize === "undefined") { await Translator.#sleep(50); return this.createTranslator(lang); } this.Aromanize = Aromanize; this.finished.ko = true; break; case "zh": if (this.OpenCC) return; if (typeof OpenCC === "undefined") { await Translator.#sleep(50); return this.createTranslator(lang); } this.OpenCC = OpenCC; this.finished.zh = true; break; } } async romajifyText(text, target = "romaji", mode = "spaced") { if (!this.finished.ja) { await Translator.#sleep(100); return this.romajifyText(text, target, mode); } return this.kuroshiro.convert(text, { to: target, mode: mode, }); } async convertToRomaja(text, target) { if (!this.finished.ko) { await Translator.#sleep(100); return this.convertToRomaja(text, target); } if (target === "hangul") return text; return Aromanize.hangulToLatin(text, "rr-translit"); } async convertChinese(text, from, target) { if (!this.finished.zh) { await Translator.#sleep(100); return this.convertChinese(text, from, target); } const converter = this.OpenCC.Converter({ from: from, to: target, }); return converter(text); } /** * Async wrapper of `setTimeout`. * * @param {number} ms * @returns {Promise} */ static async #sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } } ================================================ FILE: CustomApps/lyrics-plus/Utils.js ================================================ const Utils = { addQueueListener(callback) { Spicetify.Player.origin._events.addListener("queue_update", callback); }, removeQueueListener(callback) { Spicetify.Player.origin._events.removeListener("queue_update", callback); }, convertIntToRGB(colorInt, div = 1) { const rgb = { r: Math.round(((colorInt >> 16) & 0xff) / div), g: Math.round(((colorInt >> 8) & 0xff) / div), b: Math.round((colorInt & 0xff) / div), }; return `rgb(${rgb.r},${rgb.g},${rgb.b})`; }, /** * @param {string} s * @param {boolean} emptySymbol * @returns {string} */ normalize(s, emptySymbol = true) { let result = s .replace(/(/g, "(") .replace(/)/g, ")") .replace(/【/g, "[") .replace(/】/g, "]") .replace(/。/g, ". ") .replace(/;/g, "; ") .replace(/:/g, ": ") .replace(/?/g, "? ") .replace(/!/g, "! ") .replace(/、|,/g, ", ") .replace(/‘|’|′|'/g, "'") .replace(/“|”/g, '"') .replace(/〜/g, "~") .replace(/·|・/g, "•"); if (emptySymbol) { result = result.replace(/-/g, " ").replace(/\//g, " "); } return result.replace(/\s+/g, " ").trim(); }, /** * Check if the specified string contains Han character. * * @param {string} s * @returns {boolean} */ containsHanCharacter(s) { const hanRegex = /\p{Script=Han}/u; return hanRegex.test(s); }, /** * Singleton Translator instance for {@link toSimplifiedChinese}. * * @type {Translator | null} */ set translator(translator) { this._translatorInstance = translator; }, _translatorInstance: null, /** * Convert all Han characters to Simplified Chinese. * * Choosing Simplified Chinese makes the converted result more accurate, * as the conversion from SC to TC may have multiple possibilities, * while the conversion from TC to SC usually has only one possibility. * * @param {string} s * @returns {Promise} */ async toSimplifiedChinese(s) { // create a singleton Translator instance if (!this._translatorInstance) this.translator = new Translator("zh", true); // translate to Simplified Chinese // as Traditional Chinese differs between HK and TW, forcing to use OpenCC standard return this._translatorInstance.convertChinese(s, "t", "cn"); }, removeSongFeat(s) { return ( s .replace(/-\s+(feat|with|prod).*/i, "") .replace(/(\(|\[)(feat|with|prod)\.?\s+.*(\)|\])$/i, "") .trim() || s ); }, removeExtraInfo(s) { return s.replace(/\s-\s.*/, ""); }, capitalize(s) { return s.replace(/^(\w)/, ($1) => $1.toUpperCase()); }, detectLanguage(lyrics) { if (!Array.isArray(lyrics)) return; // Should return IETF BCP 47 language tags. // This should detect the song's main language. // Remember there is a possibility of a song referencing something in another language and the lyrics show it in that native language! const rawLyrics = lyrics[0].originalText ? lyrics.map((line) => line.originalText).join(" ") : lyrics.map((line) => line.text).join(" "); const kanaRegex = /[\u3001-\u3003]|[\u3005\u3007]|[\u301d-\u301f]|[\u3021-\u3035]|[\u3038-\u303a]|[\u3040-\u30ff]|[\uff66-\uff9f]/gu; const hangulRegex = /(\S*[\u3131-\u314e|\u314f-\u3163|\uac00-\ud7a3]+\S*)/g; const simpRegex = /[万与丑专业丛东丝丢两严丧个丬丰临为丽举么义乌乐乔习乡书买乱争于亏云亘亚产亩亲亵亸亿仅从仑仓仪们价众优伙会伛伞伟传伤伥伦伧伪伫体余佣佥侠侣侥侦侧侨侩侪侬俣俦俨俩俪俭债倾偬偻偾偿傥傧储傩儿兑兖党兰关兴兹养兽冁内冈册写军农冢冯冲决况冻净凄凉凌减凑凛几凤凫凭凯击凼凿刍划刘则刚创删别刬刭刽刿剀剂剐剑剥剧劝办务劢动励劲劳势勋勐勚匀匦匮区医华协单卖卢卤卧卫却卺厂厅历厉压厌厍厕厢厣厦厨厩厮县参叆叇双发变叙叠叶号叹叽吁后吓吕吗吣吨听启吴呒呓呕呖呗员呙呛呜咏咔咙咛咝咤咴咸哌响哑哒哓哔哕哗哙哜哝哟唛唝唠唡唢唣唤唿啧啬啭啮啰啴啸喷喽喾嗫呵嗳嘘嘤嘱噜噼嚣嚯团园囱围囵国图圆圣圹场坂坏块坚坛坜坝坞坟坠垄垅垆垒垦垧垩垫垭垯垱垲垴埘埙埚埝埯堑堕塆墙壮声壳壶壸处备复够头夸夹夺奁奂奋奖奥妆妇妈妩妪妫姗姜娄娅娆娇娈娱娲娴婳婴婵婶媪嫒嫔嫱嬷孙学孪宁宝实宠审宪宫宽宾寝对寻导寿将尔尘尧尴尸尽层屃屉届属屡屦屿岁岂岖岗岘岙岚岛岭岳岽岿峃峄峡峣峤峥峦崂崃崄崭嵘嵚嵛嵝嵴巅巩巯币帅师帏帐帘帜带帧帮帱帻帼幂幞干并广庄庆庐庑库应庙庞废庼廪开异弃张弥弪弯弹强归当录彟彦彻径徕御忆忏忧忾怀态怂怃怄怅怆怜总怼怿恋恳恶恸恹恺恻恼恽悦悫悬悭悯惊惧惨惩惫惬惭惮惯愍愠愤愦愿慑慭憷懑懒懔戆戋戏戗战戬户扎扑扦执扩扪扫扬扰抚抛抟抠抡抢护报担拟拢拣拥拦拧拨择挂挚挛挜挝挞挟挠挡挢挣挤挥挦捞损捡换捣据捻掳掴掷掸掺掼揸揽揿搀搁搂搅携摄摅摆摇摈摊撄撑撵撷撸撺擞攒敌敛数斋斓斗斩断无旧时旷旸昙昼昽显晋晒晓晔晕晖暂暧札术朴机杀杂权条来杨杩杰极构枞枢枣枥枧枨枪枫枭柜柠柽栀栅标栈栉栊栋栌栎栏树栖样栾桊桠桡桢档桤桥桦桧桨桩梦梼梾检棂椁椟椠椤椭楼榄榇榈榉槚槛槟槠横樯樱橥橱橹橼檐檩欢欤欧歼殁殇残殒殓殚殡殴毁毂毕毙毡毵氇气氢氩氲汇汉污汤汹沓沟没沣沤沥沦沧沨沩沪沵泞泪泶泷泸泺泻泼泽泾洁洒洼浃浅浆浇浈浉浊测浍济浏浐浑浒浓浔浕涂涌涛涝涞涟涠涡涢涣涤润涧涨涩淀渊渌渍渎渐渑渔渖渗温游湾湿溃溅溆溇滗滚滞滟滠满滢滤滥滦滨滩滪漤潆潇潋潍潜潴澜濑濒灏灭灯灵灾灿炀炉炖炜炝点炼炽烁烂烃烛烟烦烧烨烩烫烬热焕焖焘煅煳熘爱爷牍牦牵牺犊犟状犷犸犹狈狍狝狞独狭狮狯狰狱狲猃猎猕猡猪猫猬献獭玑玙玚玛玮环现玱玺珉珏珐珑珰珲琎琏琐琼瑶瑷璇璎瓒瓮瓯电画畅畲畴疖疗疟疠疡疬疮疯疱疴痈痉痒痖痨痪痫痴瘅瘆瘗瘘瘪瘫瘾瘿癞癣癫癯皑皱皲盏盐监盖盗盘眍眦眬着睁睐睑瞒瞩矫矶矾矿砀码砖砗砚砜砺砻砾础硁硅硕硖硗硙硚确硷碍碛碜碱碹磙礼祎祢祯祷祸禀禄禅离秃秆种积称秽秾稆税稣稳穑穷窃窍窑窜窝窥窦窭竖竞笃笋笔笕笺笼笾筑筚筛筜筝筹签简箓箦箧箨箩箪箫篑篓篮篱簖籁籴类籼粜粝粤粪粮糁糇紧絷纟纠纡红纣纤纥约级纨纩纪纫纬纭纮纯纰纱纲纳纴纵纶纷纸纹纺纻纼纽纾线绀绁绂练组绅细织终绉绊绋绌绍绎经绐绑绒结绔绕绖绗绘给绚绛络绝绞统绠绡绢绣绤绥绦继绨绩绪绫绬续绮绯绰绱绲绳维绵绶绷绸绹绺绻综绽绾绿缀缁缂缃缄缅缆缇缈缉缊缋缌缍缎缏缐缑缒缓缔缕编缗缘缙缚缛缜缝缞缟缠缡缢缣缤缥缦缧缨缩缪缫缬缭缮缯缰缱缲缳缴缵罂网罗罚罢罴羁羟羡翘翙翚耢耧耸耻聂聋职聍联聩聪肃肠肤肷肾肿胀胁胆胜胧胨胪胫胶脉脍脏脐脑脓脔脚脱脶脸腊腌腘腭腻腼腽腾膑臜舆舣舰舱舻艰艳艹艺节芈芗芜芦苁苇苈苋苌苍苎苏苘苹茎茏茑茔茕茧荆荐荙荚荛荜荞荟荠荡荣荤荥荦荧荨荩荪荫荬荭荮药莅莜莱莲莳莴莶获莸莹莺莼萚萝萤营萦萧萨葱蒇蒉蒋蒌蓝蓟蓠蓣蓥蓦蔷蔹蔺蔼蕲蕴薮藁藓虏虑虚虫虬虮虽虾虿蚀蚁蚂蚕蚝蚬蛊蛎蛏蛮蛰蛱蛲蛳蛴蜕蜗蜡蝇蝈蝉蝎蝼蝾螀螨蟏衅衔补衬衮袄袅袆袜袭袯装裆裈裢裣裤裥褛褴襁襕见观觃规觅视觇览觉觊觋觌觍觎觏觐觑觞触觯詟誉誊讠计订讣认讥讦讧讨让讪讫训议讯记讱讲讳讴讵讶讷许讹论讻讼讽设访诀证诂诃评诅识诇诈诉诊诋诌词诎诏诐译诒诓诔试诖诗诘诙诚诛诜话诞诟诠诡询诣诤该详诧诨诩诪诫诬语诮误诰诱诲诳说诵诶请诸诹诺读诼诽课诿谀谁谂调谄谅谆谇谈谊谋谌谍谎谏谐谑谒谓谔谕谖谗谘谙谚谛谜谝谞谟谠谡谢谣谤谥谦谧谨谩谪谫谬谭谮谯谰谱谲谳谴谵谶谷豮贝贞负贠贡财责贤败账货质贩贪贫贬购贮贯贰贱贲贳贴贵贶贷贸费贺贻贼贽贾贿赀赁赂赃资赅赆赇赈赉赊赋赌赍赎赏赐赑赒赓赔赕赖赗赘赙赚赛赜赝赞赟赠赡赢赣赪赵赶趋趱趸跃跄跖跞践跶跷跸跹跻踊踌踪踬踯蹑蹒蹰蹿躏躜躯车轧轨轩轪轫转轭轮软轰轱轲轳轴轵轶轷轸轹轺轻轼载轾轿辀辁辂较辄辅辆辇辈辉辊辋辌辍辎辏辐辑辒输辔辕辖辗辘辙辚辞辩辫边辽达迁过迈运还这进远违连迟迩迳迹适选逊递逦逻遗遥邓邝邬邮邹邺邻郁郄郏郐郑郓郦郧郸酝酦酱酽酾酿释里鉅鉴銮錾钆钇针钉钊钋钌钍钎钏钐钑钒钓钔钕钖钗钘钙钚钛钝钞钟钠钡钢钣钤钥钦钧钨钩钪钫钬钭钮钯钰钱钲钳钴钵钶钷钸钹钺钻钼钽钾钿铀铁铂铃铄铅铆铈铉铊铋铍铎铏铐铑铒铕铗铘铙铚铛铜铝铞铟铠铡铢铣铤铥铦铧铨铪铫铬铭铮铯铰铱铲铳铴铵银铷铸铹铺铻铼铽链铿销锁锂锃锄锅锆锇锈锉锊锋锌锍锎锏锐锑锒锓锔锕锖锗错锚锜锞锟锠锡锢锣锤锥锦锨锩锫锬锭键锯锰锱锲锳锴锵锶锷锸锹锺锻锼锽锾锿镀镁镂镃镆镇镈镉镊镌镍镎镏镐镑镒镕镖镗镙镚镛镜镝镞镟镠镡镢镣镤镥镦镧镨镩镪镫镬镭镮镯镰镱镲镳镴镶长门闩闪闫闬闭问闯闰闱闲闳间闵闶闷闸闹闺闻闼闽闾闿阀阁阂阃阄阅阆阇阈阉阊阋阌阍阎阏阐阑阒阓阔阕阖阗阘阙阚阛队阳阴阵阶际陆陇陈陉陕陧陨险随隐隶隽难雏雠雳雾霁霉霭靓静靥鞑鞒鞯鞴韦韧韨韩韪韫韬韵页顶顷顸项顺须顼顽顾顿颀颁颂颃预颅领颇颈颉颊颋颌颍颎颏颐频颒颓颔颕颖颗题颙颚颛颜额颞颟颠颡颢颣颤颥颦颧风飏飐飑飒飓飔飕飖飗飘飙飚飞飨餍饤饥饦饧饨饩饪饫饬饭饮饯饰饱饲饳饴饵饶饷饸饹饺饻饼饽饾饿馀馁馂馃馄馅馆馇馈馉馊馋馌馍馎馏馐馑馒馓馔馕马驭驮驯驰驱驲驳驴驵驶驷驸驹驺驻驼驽驾驿骀骁骂骃骄骅骆骇骈骉骊骋验骍骎骏骐骑骒骓骔骕骖骗骘骙骚骛骜骝骞骟骠骡骢骣骤骥骦骧髅髋髌鬓魇魉鱼鱽鱾鱿鲀鲁鲂鲄鲅鲆鲇鲈鲉鲊鲋鲌鲍鲎鲏鲐鲑鲒鲓鲔鲕鲖鲗鲘鲙鲚鲛鲜鲝鲞鲟鲠鲡鲢鲣鲤鲥鲦鲧鲨鲩鲪鲫鲬鲭鲮鲯鲰鲱鲲鲳鲴鲵鲶鲷鲸鲹鲺鲻鲼鲽鲾鲿鳀鳁鳂鳃鳄鳅鳆鳇鳈鳉鳊鳋鳌鳍鳎鳏鳐鳑鳒鳓鳔鳕鳖鳗鳘鳙鳛鳜鳝鳞鳟鳠鳡鳢鳣鸟鸠鸡鸢鸣鸤鸥鸦鸧鸨鸩鸪鸫鸬鸭鸮鸯鸰鸱鸲鸳鸴鸵鸶鸷鸸鸹鸺鸻鸼鸽鸾鸿鹀鹁鹂鹃鹄鹅鹆鹇鹈鹉鹊鹋鹌鹍鹎鹏鹐鹑鹒鹓鹔鹕鹖鹗鹘鹚鹛鹜鹝鹞鹟鹠鹡鹢鹣鹤鹥鹦鹧鹨鹩鹪鹫鹬鹭鹯鹰鹱鹲鹳鹴鹾麦麸黄黉黡黩黪黾鼋鼌鼍鼗鼹齄齐齑齿龀龁龂龃龄龅龆龇龈龉龊龋龌龙龚龛龟志制咨只里系范松没尝尝闹面准钟别闲干尽脏拼]/gu; const tradRegex = /[萬與醜專業叢東絲丟兩嚴喪個爿豐臨為麗舉麼義烏樂喬習鄉書買亂爭於虧雲亙亞產畝親褻嚲億僅從侖倉儀們價眾優夥會傴傘偉傳傷倀倫傖偽佇體餘傭僉俠侶僥偵側僑儈儕儂俁儔儼倆儷儉債傾傯僂僨償儻儐儲儺兒兌兗黨蘭關興茲養獸囅內岡冊寫軍農塚馮衝決況凍淨淒涼淩減湊凜幾鳳鳧憑凱擊氹鑿芻劃劉則剛創刪別剗剄劊劌剴劑剮劍剝劇勸辦務勱動勵勁勞勢勳猛勩勻匭匱區醫華協單賣盧鹵臥衛卻巹廠廳曆厲壓厭厙廁廂厴廈廚廄廝縣參靉靆雙發變敘疊葉號歎嘰籲後嚇呂嗎唚噸聽啟吳嘸囈嘔嚦唄員咼嗆嗚詠哢嚨嚀噝吒噅鹹呱響啞噠嘵嗶噦嘩噲嚌噥喲嘜嗊嘮啢嗩唕喚呼嘖嗇囀齧囉嘽嘯噴嘍嚳囁嗬噯噓嚶囑嚕劈囂謔團園囪圍圇國圖圓聖壙場阪壞塊堅壇壢壩塢墳墜壟壟壚壘墾坰堊墊埡墶壋塏堖塒塤堝墊垵塹墮壪牆壯聲殼壺壼處備複夠頭誇夾奪奩奐奮獎奧妝婦媽嫵嫗媯姍薑婁婭嬈嬌孌娛媧嫻嫿嬰嬋嬸媼嬡嬪嬙嬤孫學孿寧寶實寵審憲宮寬賓寢對尋導壽將爾塵堯尷屍盡層屭屜屆屬屢屨嶼歲豈嶇崗峴嶴嵐島嶺嶽崠巋嶨嶧峽嶢嶠崢巒嶗崍嶮嶄嶸嶔崳嶁脊巔鞏巰幣帥師幃帳簾幟帶幀幫幬幘幗冪襆幹並廣莊慶廬廡庫應廟龐廢廎廩開異棄張彌弳彎彈強歸當錄彠彥徹徑徠禦憶懺憂愾懷態慫憮慪悵愴憐總懟懌戀懇惡慟懨愷惻惱惲悅愨懸慳憫驚懼慘懲憊愜慚憚慣湣慍憤憒願懾憖怵懣懶懍戇戔戲戧戰戩戶紮撲扡執擴捫掃揚擾撫拋摶摳掄搶護報擔擬攏揀擁攔擰撥擇掛摯攣掗撾撻挾撓擋撟掙擠揮撏撈損撿換搗據撚擄摑擲撣摻摜摣攬撳攙擱摟攪攜攝攄擺搖擯攤攖撐攆擷擼攛擻攢敵斂數齋斕鬥斬斷無舊時曠暘曇晝曨顯晉曬曉曄暈暉暫曖劄術樸機殺雜權條來楊榪傑極構樅樞棗櫪梘棖槍楓梟櫃檸檉梔柵標棧櫛櫳棟櫨櫟欄樹棲樣欒棬椏橈楨檔榿橋樺檜槳樁夢檮棶檢欞槨櫝槧欏橢樓欖櫬櫚櫸檟檻檳櫧橫檣櫻櫫櫥櫓櫞簷檁歡歟歐殲歿殤殘殞殮殫殯毆毀轂畢斃氈毿氌氣氫氬氳彙漢汙湯洶遝溝沒灃漚瀝淪滄渢溈滬濔濘淚澩瀧瀘濼瀉潑澤涇潔灑窪浹淺漿澆湞溮濁測澮濟瀏滻渾滸濃潯濜塗湧濤澇淶漣潿渦溳渙滌潤澗漲澀澱淵淥漬瀆漸澠漁瀋滲溫遊灣濕潰濺漵漊潷滾滯灩灄滿瀅濾濫灤濱灘澦濫瀠瀟瀲濰潛瀦瀾瀨瀕灝滅燈靈災燦煬爐燉煒熗點煉熾爍爛烴燭煙煩燒燁燴燙燼熱煥燜燾煆糊溜愛爺牘犛牽犧犢強狀獷獁猶狽麅獮獰獨狹獅獪猙獄猻獫獵獼玀豬貓蝟獻獺璣璵瑒瑪瑋環現瑲璽瑉玨琺瓏璫琿璡璉瑣瓊瑤璦璿瓔瓚甕甌電畫暢佘疇癤療瘧癘瘍鬁瘡瘋皰屙癰痙癢瘂癆瘓癇癡癉瘮瘞瘺癟癱癮癭癩癬癲臒皚皺皸盞鹽監蓋盜盤瞘眥矓著睜睞瞼瞞矚矯磯礬礦碭碼磚硨硯碸礪礱礫礎硜矽碩硤磽磑礄確鹼礙磧磣堿镟滾禮禕禰禎禱禍稟祿禪離禿稈種積稱穢穠穭稅穌穩穡窮竊竅窯竄窩窺竇窶豎競篤筍筆筧箋籠籩築篳篩簹箏籌簽簡籙簀篋籜籮簞簫簣簍籃籬籪籟糴類秈糶糲粵糞糧糝餱緊縶糸糾紆紅紂纖紇約級紈纊紀紉緯紜紘純紕紗綱納紝縱綸紛紙紋紡紵紖紐紓線紺絏紱練組紳細織終縐絆紼絀紹繹經紿綁絨結絝繞絰絎繪給絢絳絡絕絞統綆綃絹繡綌綏絛繼綈績緒綾緓續綺緋綽緔緄繩維綿綬繃綢綯綹綣綜綻綰綠綴緇緙緗緘緬纜緹緲緝縕繢緦綞緞緶線緱縋緩締縷編緡緣縉縛縟縝縫縗縞纏縭縊縑繽縹縵縲纓縮繆繅纈繚繕繒韁繾繰繯繳纘罌網羅罰罷羆羈羥羨翹翽翬耮耬聳恥聶聾職聹聯聵聰肅腸膚膁腎腫脹脅膽勝朧腖臚脛膠脈膾髒臍腦膿臠腳脫腡臉臘醃膕齶膩靦膃騰臏臢輿艤艦艙艫艱豔艸藝節羋薌蕪蘆蓯葦藶莧萇蒼苧蘇檾蘋莖蘢蔦塋煢繭荊薦薘莢蕘蓽蕎薈薺蕩榮葷滎犖熒蕁藎蓀蔭蕒葒葤藥蒞蓧萊蓮蒔萵薟獲蕕瑩鶯蓴蘀蘿螢營縈蕭薩蔥蕆蕢蔣蔞藍薊蘺蕷鎣驀薔蘞藺藹蘄蘊藪槁蘚虜慮虛蟲虯蟣雖蝦蠆蝕蟻螞蠶蠔蜆蠱蠣蟶蠻蟄蛺蟯螄蠐蛻蝸蠟蠅蟈蟬蠍螻蠑螿蟎蠨釁銜補襯袞襖嫋褘襪襲襏裝襠褌褳襝褲襇褸襤繈襴見觀覎規覓視覘覽覺覬覡覿覥覦覯覲覷觴觸觶讋譽謄訁計訂訃認譏訐訌討讓訕訖訓議訊記訒講諱謳詎訝訥許訛論訩訟諷設訪訣證詁訶評詛識詗詐訴診詆謅詞詘詔詖譯詒誆誄試詿詩詰詼誠誅詵話誕詬詮詭詢詣諍該詳詫諢詡譸誡誣語誚誤誥誘誨誑說誦誒請諸諏諾讀諑誹課諉諛誰諗調諂諒諄誶談誼謀諶諜謊諫諧謔謁謂諤諭諼讒諮諳諺諦謎諞諝謨讜謖謝謠謗諡謙謐謹謾謫譾謬譚譖譙讕譜譎讞譴譫讖穀豶貝貞負貟貢財責賢敗賬貨質販貪貧貶購貯貫貳賤賁貰貼貴貺貸貿費賀貽賊贄賈賄貲賃賂贓資賅贐賕賑賚賒賦賭齎贖賞賜贔賙賡賠賧賴賵贅賻賺賽賾贗讚贇贈贍贏贛赬趙趕趨趲躉躍蹌蹠躒踐躂蹺蹕躚躋踴躊蹤躓躑躡蹣躕躥躪躦軀車軋軌軒軑軔轉軛輪軟轟軲軻轤軸軹軼軤軫轢軺輕軾載輊轎輈輇輅較輒輔輛輦輩輝輥輞輬輟輜輳輻輯轀輸轡轅轄輾轆轍轔辭辯辮邊遼達遷過邁運還這進遠違連遲邇逕跡適選遜遞邐邏遺遙鄧鄺鄔郵鄒鄴鄰鬱郤郟鄶鄭鄆酈鄖鄲醞醱醬釅釃釀釋裏钜鑒鑾鏨釓釔針釘釗釙釕釷釺釧釤鈒釩釣鍆釹鍚釵鈃鈣鈈鈦鈍鈔鍾鈉鋇鋼鈑鈐鑰欽鈞鎢鉤鈧鈁鈥鈄鈕鈀鈺錢鉦鉗鈷缽鈳鉕鈽鈸鉞鑽鉬鉭鉀鈿鈾鐵鉑鈴鑠鉛鉚鈰鉉鉈鉍鈹鐸鉶銬銠鉺銪鋏鋣鐃銍鐺銅鋁銱銦鎧鍘銖銑鋌銩銛鏵銓鉿銚鉻銘錚銫鉸銥鏟銃鐋銨銀銣鑄鐒鋪鋙錸鋱鏈鏗銷鎖鋰鋥鋤鍋鋯鋨鏽銼鋝鋒鋅鋶鐦鐧銳銻鋃鋟鋦錒錆鍺錯錨錡錁錕錩錫錮鑼錘錐錦鍁錈錇錟錠鍵鋸錳錙鍥鍈鍇鏘鍶鍔鍤鍬鍾鍛鎪鍠鍰鎄鍍鎂鏤鎡鏌鎮鎛鎘鑷鐫鎳鎿鎦鎬鎊鎰鎔鏢鏜鏍鏰鏞鏡鏑鏃鏇鏐鐔钁鐐鏷鑥鐓鑭鐠鑹鏹鐙鑊鐳鐶鐲鐮鐿鑔鑣鑞鑲長門閂閃閆閈閉問闖閏闈閑閎間閔閌悶閘鬧閨聞闥閩閭闓閥閣閡閫鬮閱閬闍閾閹閶鬩閿閽閻閼闡闌闃闠闊闋闔闐闒闕闞闤隊陽陰陣階際陸隴陳陘陝隉隕險隨隱隸雋難雛讎靂霧霽黴靄靚靜靨韃鞽韉韝韋韌韍韓韙韞韜韻頁頂頃頇項順須頊頑顧頓頎頒頌頏預顱領頗頸頡頰頲頜潁熲頦頤頻頮頹頷頴穎顆題顒顎顓顏額顳顢顛顙顥纇顫顬顰顴風颺颭颮颯颶颸颼颻飀飄飆飆飛饗饜飣饑飥餳飩餼飪飫飭飯飲餞飾飽飼飿飴餌饒餉餄餎餃餏餅餑餖餓餘餒餕餜餛餡館餷饋餶餿饞饁饃餺餾饈饉饅饊饌饢馬馭馱馴馳驅馹駁驢駔駛駟駙駒騶駐駝駑駕驛駘驍罵駰驕驊駱駭駢驫驪騁驗騂駸駿騏騎騍騅騌驌驂騙騭騤騷騖驁騮騫騸驃騾驄驏驟驥驦驤髏髖髕鬢魘魎魚魛魢魷魨魯魴魺鮁鮃鯰鱸鮋鮓鮒鮊鮑鱟鮍鮐鮭鮚鮳鮪鮞鮦鰂鮜鱠鱭鮫鮮鮺鯗鱘鯁鱺鰱鰹鯉鰣鰷鯀鯊鯇鮶鯽鯒鯖鯪鯕鯫鯡鯤鯧鯝鯢鯰鯛鯨鯵鯴鯔鱝鰈鰏鱨鯷鰮鰃鰓鱷鰍鰒鰉鰁鱂鯿鰠鼇鰭鰨鰥鰩鰟鰜鰳鰾鱈鱉鰻鰵鱅鰼鱖鱔鱗鱒鱯鱤鱧鱣鳥鳩雞鳶鳴鳲鷗鴉鶬鴇鴆鴣鶇鸕鴨鴞鴦鴒鴟鴝鴛鴬鴕鷥鷙鴯鴰鵂鴴鵃鴿鸞鴻鵐鵓鸝鵑鵠鵝鵒鷳鵜鵡鵲鶓鵪鶤鵯鵬鵮鶉鶊鵷鷫鶘鶡鶚鶻鶿鶥鶩鷊鷂鶲鶹鶺鷁鶼鶴鷖鸚鷓鷚鷯鷦鷲鷸鷺鸇鷹鸌鸏鸛鸘鹺麥麩黃黌黶黷黲黽黿鼂鼉鞀鼴齇齊齏齒齔齕齗齟齡齙齠齜齦齬齪齲齷龍龔龕龜誌製谘隻裡係範鬆冇嚐嘗鬨麵準鐘彆閒乾儘臟拚]/gu; const hanziRegex = /\p{Script=Han}/gu; const cjkMatch = rawLyrics.match( new RegExp(`${kanaRegex.source}|${hanziRegex.source}|${hangulRegex.source}|${/\p{Unified_Ideograph}/gu.source}`, "gu") ); if (!cjkMatch) return; const kanaCount = cjkMatch.filter((glyph) => kanaRegex.test(glyph)).length; const hanziCount = cjkMatch.filter((glyph) => hanziRegex.test(glyph)).length; const simpCount = cjkMatch.filter((glyph) => simpRegex.test(glyph)).length; const tradCount = cjkMatch.filter((glyph) => tradRegex.test(glyph)).length; const kanaPercentage = kanaCount / cjkMatch.length; const hanziPercentage = hanziCount / cjkMatch.length; const simpPercentage = simpCount / cjkMatch.length; const tradPercentage = tradCount / cjkMatch.length; if (cjkMatch.filter((glyph) => hangulRegex.test(glyph)).length !== 0) { return "ko"; } if (((kanaPercentage - hanziPercentage + 1) / 2) * 100 >= CONFIG.visual["ja-detect-threshold"]) { return "ja"; } return ((simpPercentage - tradPercentage + 1) / 2) * 100 >= CONFIG.visual["hans-detect-threshold"] ? "zh-hans" : "zh-hant"; }, processTranslatedLyrics(translated, original) { return original.map((lyric, index) => ({ startTime: lyric.startTime || 0, text: this.rubyTextToReact(translated[index]), originalText: lyric.text, })); }, /** It seems that this function is not being used, but I'll keep it just in case it’s needed in the future.*/ processTranslatedOriginalLyrics(lyrics, synced) { const data = []; const dataSouce = {}; for (const item of lyrics) { dataSouce[item.startTime] = { translate: item.text }; } for (const time in synced) { dataSouce[item.startTime] = { ...dataSouce[item.startTime], text: item.text, }; } for (const time in dataSouce) { const item = dataSouce[time]; const lyric = { startTime: time || 0, text: this.rubyTextToOriginalReact(item.translate || item.text, item.text || item.translate), }; data.push(lyric); } return data; }, rubyTextToOriginalReact(translated, syncedText) { const react = Spicetify.React; return react.createElement("p1", null, [react.createElement("ruby", {}, syncedText, react.createElement("rt", null, translated))]); }, rubyTextToReact(s) { const react = Spicetify.React; const rubyElems = s.split(""); const reactChildren = []; reactChildren.push(rubyElems[0]); for (let i = 1; i < rubyElems.length; i++) { const kanji = rubyElems[i].split("")[0]; const furigana = rubyElems[i].split("")[1].split("")[0]; reactChildren.push(react.createElement("ruby", null, kanji, react.createElement("rt", null, furigana))); reactChildren.push(rubyElems[i].split("")[1]); } return react.createElement("p1", null, reactChildren); }, formatTime(timestamp) { if (Number.isNaN(timestamp)) return timestamp.toString(); let minutes = Math.trunc(timestamp / 60000); let seconds = ((timestamp - minutes * 60000) / 1000).toFixed(2); if (minutes < 10) minutes = `0${minutes}`; if (seconds < 10) seconds = `0${seconds}`; return `${minutes}:${seconds}`; }, formatTextWithTimestamps(text, startTime = 0) { if (text.props?.children) { return text.props.children .map((child) => { if (typeof child === "string") { return child; } if (child.props?.children) { return child.props?.children[0]; } }) .join(""); } if (Array.isArray(text)) { let wordTime = startTime; return text .map((word) => { wordTime += word.time; return `${word.word}<${this.formatTime(wordTime)}>`; }) .join(""); } return text; }, convertParsedToLRC(lyrics, isBelow) { let original = ""; let conver = ""; if (isBelow) { for (const line of lyrics) { original += `[${this.formatTime(line.startTime)}]${this.formatTextWithTimestamps(line.originalText, line.startTime)}\n`; conver += `[${this.formatTime(line.startTime)}]${this.formatTextWithTimestamps(line.text, line.startTime)}\n`; } } else { for (const line of lyrics) { original += `[${this.formatTime(line.startTime)}]${this.formatTextWithTimestamps(line.text, line.startTime)}\n`; } } return { original, conver, }; }, convertParsedToUnsynced(lyrics, isBelow) { let original = ""; let conver = ""; if (isBelow) { for (const line of lyrics) { if (typeof line.originalText === "object") { original += `${line.originalText?.props?.children?.[0]}\n`; } else { original += `${line.originalText}\n`; } if (typeof line.text === "object") { conver += `${line.text?.props?.children?.[0]}\n`; } else { conver += `${line.text}\n`; } } } else { for (const line of lyrics) { if (typeof line.text === "object") { original += `${line.text?.props?.children?.[0]}\n`; } else { original += `${line.text}\n`; } } } return { original, conver, }; }, parseLocalLyrics(lyrics) { // Preprocess lyrics by removing [tags] and empty lines const lines = lyrics .replaceAll(/\[[a-zA-Z]+:.+\]/g, "") .trim() .split("\n"); const syncedTimestamp = /\[([0-9:.]+)\]/; const karaokeTimestamp = /<([0-9:.]+)>/; const unsynced = []; const isSynced = lines[0].match(syncedTimestamp); const synced = isSynced ? [] : null; const isKaraoke = lines[0].match(karaokeTimestamp); const karaoke = isKaraoke ? [] : null; function timestampToMs(timestamp) { const [minutes, seconds] = timestamp.replace(/\[\]<>/, "").split(":"); return Number(minutes) * 60 * 1000 + Number(seconds) * 1000; } function parseKaraokeLine(line, startTime) { let wordTime = timestampToMs(startTime); const karaokeLine = []; const karaoke = line.matchAll(/(\S+ ?)<([0-9:.]+)>/g); for (const match of karaoke) { const word = match[1]; const time = match[2]; karaokeLine.push({ word, time: timestampToMs(time) - wordTime }); wordTime = timestampToMs(time); } return karaokeLine; } for (const [i, line] of lines.entries()) { const time = line.match(syncedTimestamp)?.[1]; let lyricContent = line.replace(syncedTimestamp, "").trim(); const lyric = lyricContent.replaceAll(/<([0-9:.]+)>/g, "").trim(); if (line.trim() !== "") { if (isKaraoke) { if (!lyricContent.endsWith(">")) { // For some reason there are a variety of formats for karaoke lyrics, Wikipedia is also inconsisent in their examples const endTime = lines[i + 1]?.match(syncedTimestamp)?.[1] || this.formatTime(Number(Spicetify.Player.data.item.metadata.duration)); lyricContent += `<${endTime}>`; } const karaokeLine = parseKaraokeLine(lyricContent, time); karaoke.push({ text: karaokeLine, startTime: timestampToMs(time) }); } isSynced && time && synced.push({ text: lyric || "♪", startTime: timestampToMs(time) }); unsynced.push({ text: lyric || "♪" }); } } return { synced, unsynced, karaoke }; }, processLyrics(lyrics) { return lyrics .replace(/ | /g, "") // Remove space .replace(/[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~?!,。、《》【】「」]/g, ""); // Remove punctuation }, }; ================================================ FILE: CustomApps/lyrics-plus/index.js ================================================ // Run "npm i @types/react" to have this type package available in workspace /// /// /** @type {React} */ const react = Spicetify.React; const { useState, useEffect, useCallback, useMemo, useRef } = react; /** @type {import("react").ReactDOM} */ const spotifyVersion = Spicetify.Platform.version; // Define a function called "render" to specify app entry point // This function will be used to mount app to main view. function render() { return react.createElement(LyricsContainer, null); } function getConfig(name, defaultVal = true) { const value = localStorage.getItem(name); return value ? value === "true" : defaultVal; } const APP_NAME = "lyrics-plus"; const MUSIXMATCH_TRANSLATION_PREFIX_DEFAULT = "musixmatchTranslation:"; const MUSIXMATCH_TRANSLATION_PREFIX_GLOBAL_KEY = "__lyricsPlusMusixmatchTranslationPrefix"; const MUSIXMATCH_TRANSLATION_FETCH_MESSAGE = "Fetching translation..."; const MUSIXMATCH_TRANSLATION_FETCH_FAILED_MESSAGE = "Failed to fetch translation, please try again in a few minutes"; const MUSIXMATCH_TRANSLATION_PREFIX = typeof window !== "undefined" && typeof window[MUSIXMATCH_TRANSLATION_PREFIX_GLOBAL_KEY] === "string" ? window[MUSIXMATCH_TRANSLATION_PREFIX_GLOBAL_KEY] : MUSIXMATCH_TRANSLATION_PREFIX_DEFAULT; if (typeof window !== "undefined") { window[MUSIXMATCH_TRANSLATION_PREFIX_GLOBAL_KEY] = MUSIXMATCH_TRANSLATION_PREFIX; } const KARAOKE = 0; const SYNCED = 1; const UNSYNCED = 2; const GENIUS = 3; const CONFIG = { visual: { "playbar-button": getConfig("lyrics-plus:visual:playbar-button", false), colorful: getConfig("lyrics-plus:visual:colorful"), noise: getConfig("lyrics-plus:visual:noise"), "background-color": localStorage.getItem("lyrics-plus:visual:background-color") || "var(--spice-main)", "active-color": localStorage.getItem("lyrics-plus:visual:active-color") || "var(--spice-text)", "inactive-color": localStorage.getItem("lyrics-plus:visual:inactive-color") || "rgba(var(--spice-rgb-subtext),0.5)", "highlight-color": localStorage.getItem("lyrics-plus:visual:highlight-color") || "var(--spice-button)", alignment: localStorage.getItem("lyrics-plus:visual:alignment") || "center", "lines-before": localStorage.getItem("lyrics-plus:visual:lines-before") || "0", "lines-after": localStorage.getItem("lyrics-plus:visual:lines-after") || "2", "font-size": localStorage.getItem("lyrics-plus:visual:font-size") || "32", "translate:translated-lyrics-source": localStorage.getItem("lyrics-plus:visual:translate:translated-lyrics-source") || "none", "translate:display-mode": localStorage.getItem("lyrics-plus:visual:translate:display-mode") || "replace", "translate:detect-language-override": localStorage.getItem("lyrics-plus:visual:translate:detect-language-override") || "off", "translation-mode:japanese": localStorage.getItem("lyrics-plus:visual:translation-mode:japanese") || "furigana", "translation-mode:korean": localStorage.getItem("lyrics-plus:visual:translation-mode:korean") || "romaja", "translation-mode:chinese": localStorage.getItem("lyrics-plus:visual:translation-mode:chinese") || "cn", translate: getConfig("lyrics-plus:visual:translate", false), "ja-detect-threshold": localStorage.getItem("lyrics-plus:visual:ja-detect-threshold") || "40", "hans-detect-threshold": localStorage.getItem("lyrics-plus:visual:hans-detect-threshold") || "40", "musixmatch-translation-language": localStorage.getItem("lyrics-plus:visual:musixmatch-translation-language") || "none", "fade-blur": getConfig("lyrics-plus:visual:fade-blur"), "fullscreen-key": localStorage.getItem("lyrics-plus:visual:fullscreen-key") || "f12", "show-performers": getConfig("lyrics-plus:visual:show-performers", true), "synced-compact": getConfig("lyrics-plus:visual:synced-compact"), "dual-genius": getConfig("lyrics-plus:visual:dual-genius"), "global-delay": Number(localStorage.getItem("lyrics-plus:visual:global-delay")) || 0, delay: 0, }, providers: { lrclib: { on: getConfig("lyrics-plus:provider:lrclib:on"), desc: "Lyrics sourced from lrclib.net. Supports both synced and unsynced lyrics. LRCLIB is a free and open-source lyrics provider.", modes: [SYNCED, UNSYNCED], }, musixmatch: { on: getConfig("lyrics-plus:provider:musixmatch:on"), desc: "Fully compatible with Spotify. Requires a token that can be retrieved from the official Musixmatch app. If you have problems with retrieving lyrics, try refreshing the token by clicking Refresh Token button. You may need to be forced to use your own CORS Proxy to use this provider.", token: localStorage.getItem("lyrics-plus:provider:musixmatch:token") || "21051986b9886beabe1ce01c3ce94c96319411f8f2c122676365e3", modes: [KARAOKE, SYNCED, UNSYNCED], }, spotify: { on: getConfig("lyrics-plus:provider:spotify:on"), desc: "Lyrics sourced from official Spotify API.", modes: [SYNCED, UNSYNCED], }, netease: { on: getConfig("lyrics-plus:provider:netease:on", false), desc: "Crowdsourced lyrics provider ran by Chinese developers and users.", modes: [KARAOKE, SYNCED, UNSYNCED], }, genius: { on: spotifyVersion >= "1.2.31" ? false : getConfig("lyrics-plus:provider:genius:on"), desc: "Provide unsynced lyrics with insights from artists themselves. Genius is disabled and cannot be used as a provider on 1.2.31 and higher.", modes: [GENIUS], }, local: { on: getConfig("lyrics-plus:provider:local:on"), desc: "Provide lyrics from cache/local files loaded from previous Spotify sessions.", modes: [KARAOKE, SYNCED, UNSYNCED], }, }, providersOrder: localStorage.getItem("lyrics-plus:services-order"), modes: ["karaoke", "synced", "unsynced", "genius"], locked: localStorage.getItem("lyrics-plus:lock-mode") || "-1", }; try { CONFIG.providersOrder = JSON.parse(CONFIG.providersOrder); if (!Array.isArray(CONFIG.providersOrder) || Object.keys(CONFIG.providers).length !== CONFIG.providersOrder.length) { throw ""; } } catch { CONFIG.providersOrder = Object.keys(CONFIG.providers); localStorage.setItem("lyrics-plus:services-order", JSON.stringify(CONFIG.providersOrder)); } CONFIG.locked = Number.parseInt(CONFIG.locked); CONFIG.visual["lines-before"] = Number.parseInt(CONFIG.visual["lines-before"]); CONFIG.visual["lines-after"] = Number.parseInt(CONFIG.visual["lines-after"]); CONFIG.visual["font-size"] = Number.parseInt(CONFIG.visual["font-size"]); CONFIG.visual["ja-detect-threshold"] = Number.parseInt(CONFIG.visual["ja-detect-threshold"]); CONFIG.visual["hans-detect-threshold"] = Number.parseInt(CONFIG.visual["hans-detect-threshold"]); if (CONFIG.visual["translate:translated-lyrics-source"] === "musixmatchTranslation") { const language = CONFIG.visual["musixmatch-translation-language"]; const normalizedLanguage = language && language !== "none" ? language : "none"; const upgradedValue = normalizedLanguage !== "none" ? `${MUSIXMATCH_TRANSLATION_PREFIX}${normalizedLanguage}` : "none"; CONFIG.visual["translate:translated-lyrics-source"] = upgradedValue; localStorage.setItem(`${APP_NAME}:visual:translate:translated-lyrics-source`, upgradedValue); } if (typeof CONFIG.visual["translate:translated-lyrics-source"] === "string") { const sourceValue = CONFIG.visual["translate:translated-lyrics-source"]; if (sourceValue.startsWith(MUSIXMATCH_TRANSLATION_PREFIX)) { const language = sourceValue.slice(MUSIXMATCH_TRANSLATION_PREFIX.length) || "none"; if (CONFIG.visual["musixmatch-translation-language"] !== language) { CONFIG.visual["musixmatch-translation-language"] = language; localStorage.setItem(`${APP_NAME}:visual:musixmatch-translation-language`, language); } } } if ( CONFIG.visual.translate && typeof CONFIG.visual["translate:translated-lyrics-source"] === "string" && CONFIG.visual["translate:translated-lyrics-source"] !== "none" ) { CONFIG.visual.translate = false; localStorage.setItem(`${APP_NAME}:visual:translate`, "false"); } let CACHE = {}; const emptyState = { karaoke: null, synced: null, unsynced: null, genius: null, genius2: null, currentLyrics: null, musixmatchAvailableTranslations: null, musixmatchTrackId: null, musixmatchTranslationLanguage: null, }; let lyricContainerUpdate; let reloadLyrics; let refreshMusixmatchTranslation; const fontSizeLimit = { min: 16, max: 256, step: 4 }; const thresholdSizeLimit = { min: 0, max: 100, step: 5 }; function resolveTranslationSource(source) { if (typeof source !== "string") { return { key: source, language: null }; } if (source.startsWith(MUSIXMATCH_TRANSLATION_PREFIX)) { const language = source.slice(MUSIXMATCH_TRANSLATION_PREFIX.length) || null; return { key: "musixmatchTranslation", language }; } return { key: source, language: null }; } class LyricsContainer extends react.Component { constructor() { super(); this.state = { karaoke: null, synced: null, unsynced: null, genius: null, genius2: null, currentLyrics: null, romaji: null, furigana: null, hiragana: null, hangul: null, romaja: null, katakana: null, cn: null, hk: null, tw: null, musixmatchTranslation: null, musixmatchTranslationLanguage: null, musixmatchAvailableTranslations: [], musixmatchTrackId: null, neteaseTranslation: null, uri: "", provider: "", colors: { background: "", inactive: "", }, tempo: "0.25s", explicitMode: -1, lockMode: CONFIG.locked, mode: -1, isLoading: false, versionIndex: 0, versionIndex2: 0, isFullscreen: false, isFADMode: false, isCached: false, language: null, }; this.currentTrackUri = ""; this.nextTrackUri = ""; this.availableModes = []; this.styleVariables = {}; this.fullscreenContainer = document.createElement("div"); this.fullscreenContainer.id = "lyrics-fullscreen-container"; this.mousetrap = null; this.containerRef = react.createRef(null); this.translator = null; this.initMoustrap(); // Cache last state this.languageOverride = CONFIG.visual["translate:detect-language-override"]; this.translate = CONFIG.visual.translate; this.reRenderLyricsPage = false; this.displayMode = null; this.currentMusixmatchLanguage = CONFIG.visual["musixmatch-translation-language"]; this._musixmatchTranslationRequestId = null; } infoFromTrack(track) { const meta = track?.metadata; if (!meta) { return null; } return { duration: Number(meta.duration), album: meta.album_title, artist: meta.artist_name, title: meta.title, uri: track.uri, image: meta.image_url, }; } async fetchColors(uri) { let vibrant = 0; try { try { const { fetchExtractedColorForTrackEntity } = Spicetify.GraphQL.Definitions; const { data } = await Spicetify.GraphQL.Request(fetchExtractedColorForTrackEntity, { uri }); const { hex } = data.trackUnion.albumOfTrack.coverArt.extractedColors.colorDark; vibrant = Number.parseInt(hex.replace("#", ""), 16); } catch { const colors = await Spicetify.CosmosAsync.get(`https://spclient.wg.spotify.com/colorextractor/v1/extract-presets?uri=${uri}&format=json`); vibrant = colors.entries[0].color_swatches.find((color) => color.preset === "VIBRANT_NON_ALARMING").color; } } catch { vibrant = 8747370; } this.setState({ colors: { background: Utils.convertIntToRGB(vibrant), inactive: Utils.convertIntToRGB(vibrant, 3), }, }); } async fetchTempo(uri) { const audio = await Spicetify.CosmosAsync.get( `https://spclient.wg.spotify.com/audio-attributes/v1/audio-features/${uri.split(":")[2]}?format=json` ); let tempo = audio.tempo; const MIN_TEMPO = 60; const MAX_TEMPO = 150; const MAX_PERIOD = 0.4; if (!tempo) tempo = 105; if (tempo < MIN_TEMPO) tempo = MIN_TEMPO; if (tempo > MAX_TEMPO) tempo = MAX_TEMPO; let period = MAX_PERIOD - ((tempo - MIN_TEMPO) / (MAX_TEMPO - MIN_TEMPO)) * MAX_PERIOD; period = Math.round(period * 100) / 100; this.setState({ tempo: `${String(period)}s`, }); } async refreshMusixmatchTranslation() { const selectedLanguage = CONFIG.visual["musixmatch-translation-language"] || "none"; const availableTranslations = this.state.musixmatchAvailableTranslations || []; const trackId = this.state.musixmatchTrackId; const currentUri = this.state.uri; const currentRequestId = Symbol("musixmatchTranslationRequest"); this._musixmatchTranslationRequestId = currentRequestId; const isLatestRequest = () => this._musixmatchTranslationRequestId === currentRequestId; const finishRequest = () => { if (isLatestRequest()) { this._musixmatchTranslationRequestId = null; } }; const clearTranslation = () => { if (this.state.musixmatchTranslation !== null || this.state.musixmatchTranslationLanguage !== null) { this.setState({ musixmatchTranslation: null, musixmatchTranslationLanguage: null, }); } if (CACHE[currentUri]) { CACHE[currentUri].musixmatchTranslation = null; CACHE[currentUri].musixmatchTranslationLanguage = null; } }; if (!trackId || !selectedLanguage || selectedLanguage === "none") { clearTranslation(); finishRequest(); return; } if (!availableTranslations.includes(selectedLanguage)) { clearTranslation(); finishRequest(); return; } const baseLyrics = this.state.synced ?? this.state.unsynced; if (!baseLyrics) { finishRequest(); return; } const currentLanguage = selectedLanguage; Spicetify.showNotification(MUSIXMATCH_TRANSLATION_FETCH_MESSAGE, false, 1000); this.setState({ musixmatchTranslation: null, musixmatchTranslationLanguage: null, }); let translation; try { translation = await ProviderMusixmatch.getTranslation(trackId); } catch (error) { console.error(error); if (isLatestRequest()) { Spicetify.showNotification(MUSIXMATCH_TRANSLATION_FETCH_FAILED_MESSAGE, true, 3000); if (CACHE[currentUri]) { CACHE[currentUri].musixmatchTranslation = null; CACHE[currentUri].musixmatchTranslationLanguage = null; } } finishRequest(); return; } if (!translation) { if (isLatestRequest()) { Spicetify.showNotification(MUSIXMATCH_TRANSLATION_FETCH_FAILED_MESSAGE, true, 3000); if (CACHE[currentUri]) { CACHE[currentUri].musixmatchTranslation = null; CACHE[currentUri].musixmatchTranslationLanguage = null; } } finishRequest(); return; } if ( currentLanguage !== CONFIG.visual["musixmatch-translation-language"] || trackId !== this.state.musixmatchTrackId || currentUri !== this.state.uri || !isLatestRequest() ) { finishRequest(); return; } const latestBaseLyrics = this.state.synced ?? this.state.unsynced; if (!latestBaseLyrics) { finishRequest(); return; } const mappedTranslation = latestBaseLyrics.map((line) => { const originalText = line.originalText ?? line.text; const matched = translation.find((entry) => Utils.processLyrics(entry.matchedLine) === Utils.processLyrics(originalText)); return { ...line, text: matched?.translation ?? line.text, originalText, }; }); if (!isLatestRequest()) { finishRequest(); return; } this.setState({ musixmatchTranslation: mappedTranslation, musixmatchTranslationLanguage: currentLanguage, }); if (CACHE[currentUri]) { CACHE[currentUri].musixmatchTranslation = mappedTranslation; CACHE[currentUri].musixmatchTranslationLanguage = currentLanguage; } finishRequest(); } async tryServices(trackInfo, mode = -1) { const currentMode = CONFIG.modes[mode] || ""; let finalData = { ...emptyState, uri: trackInfo.uri }; for (const id of CONFIG.providersOrder) { const service = CONFIG.providers[id]; if (spotifyVersion >= "1.2.31" && id === "genius") continue; if (!service.on) continue; if (mode !== -1 && !service.modes.includes(mode)) continue; let data; try { data = await Providers[id](trackInfo); } catch (e) { console.error(e); continue; } if (data.error || (!data.karaoke && !data.synced && !data.unsynced && !data.genius)) continue; if (mode === -1) { finalData = data; return finalData; } if (!data[currentMode]) { for (const key in data) { if (!finalData[key]) { finalData[key] = data[key]; } } continue; } for (const key in data) { if (!finalData[key]) { finalData[key] = data[key]; } } if (data.provider !== "local" && finalData.provider && finalData.provider !== data.provider) { const styledMode = currentMode.charAt(0).toUpperCase() + currentMode.slice(1); finalData.copyright = `${styledMode} lyrics provided by ${data.provider}\n${finalData.copyright || ""}`.trim(); } if (finalData.musixmatchTranslation && typeof finalData.musixmatchTranslation[0].startTime === "undefined" && finalData.synced) { finalData.musixmatchTranslation = finalData.synced.map((line) => ({ ...line, text: finalData.musixmatchTranslation.find((l) => Utils.processLyrics(l.originalText) === Utils.processLyrics(line.text))?.text ?? line.text, })); } return finalData; } return finalData; } async fetchLyrics(track, mode = -1, refresh = false) { const info = this.infoFromTrack(track); if (!info) { this.setState({ error: "No track info" }); return; } let isCached = this.lyricsSaved(info.uri); if (CONFIG.visual.colorful) { this.fetchColors(info.uri); } this.fetchTempo(info.uri); this.resetDelay(); let tempState; // if lyrics are cached if ((mode === -1 && CACHE[info.uri]) || CACHE[info.uri]?.[CONFIG.modes?.[mode]]) { tempState = { ...emptyState, ...CACHE[info.uri], isCached }; if (CACHE[info.uri]?.mode) { this.state.explicitMode = CACHE[info.uri]?.mode; tempState = { ...tempState, mode: CACHE[info.uri]?.mode }; } } else { this.setState({ ...emptyState, isLoading: true, isCached: false }); const resp = await this.tryServices(info, mode); if (resp.provider) { // Cache lyrics CACHE[resp.uri] = resp; } // This True when the user presses the Cache Lyrics button and saves it to localStorage. isCached = this.lyricsSaved(resp.uri); // In case user skips tracks too fast and multiple callbacks // set wrong lyrics to current track. if (resp.uri === this.currentTrackUri) { tempState = { ...emptyState, ...resp, isLoading: false, isCached }; } else { return; } } const selectedMusixmatchLanguage = CONFIG.visual["musixmatch-translation-language"] || "none"; const shouldRefreshMusixmatchTranslation = tempState.musixmatchTrackId && selectedMusixmatchLanguage !== "none" && Array.isArray(tempState.musixmatchAvailableTranslations) && tempState.musixmatchAvailableTranslations.includes(selectedMusixmatchLanguage) && (tempState.musixmatchTranslationLanguage !== selectedMusixmatchLanguage || !tempState.musixmatchTranslation); if ( selectedMusixmatchLanguage !== "none" && (!Array.isArray(tempState.musixmatchAvailableTranslations) || !tempState.musixmatchAvailableTranslations.includes(selectedMusixmatchLanguage)) ) { if ( typeof CONFIG.visual["translate:translated-lyrics-source"] === "string" && CONFIG.visual["translate:translated-lyrics-source"].startsWith(MUSIXMATCH_TRANSLATION_PREFIX) ) { CONFIG.visual["translate:translated-lyrics-source"] = "none"; localStorage.setItem(`${APP_NAME}:visual:translate:translated-lyrics-source`, "none"); } CONFIG.visual["musixmatch-translation-language"] = "none"; localStorage.setItem(`${APP_NAME}:visual:musixmatch-translation-language`, "none"); } const translationOverrides = shouldRefreshMusixmatchTranslation ? { musixmatchTranslation: null, musixmatchTranslationLanguage: null } : {}; let finalMode = mode; if (mode === -1) { if (this.state.explicitMode !== -1) { finalMode = this.state.explicitMode; } else if (this.state.lockMode !== -1) { finalMode = this.state.lockMode; } else { // Auto switch if (tempState.karaoke) { finalMode = KARAOKE; } else if (tempState.synced) { finalMode = SYNCED; } else if (tempState.unsynced) { finalMode = UNSYNCED; } else if (tempState.genius) { finalMode = GENIUS; } } } this.lyricsSource(tempState, finalMode); // if song changed one time if (tempState.uri !== this.state.uri || refresh) { // when a song starts for the first time and language-override is selected, the lyrics are converted to the specified language. // however, when switching it off again, the detected language needs to be known, so defaultLanguage has been introduced. const defaultLanguage = Utils.detectLanguage(this.state.currentLyrics); const language = CONFIG.visual["translate:detect-language-override"] !== "off" ? CONFIG.visual["translate:detect-language-override"] : defaultLanguage; const friendlyLanguage = language && new Intl.DisplayNames(["en"], { type: "language" }).of(language.split("-")[0])?.toLowerCase(); const targetConvert = CONFIG.visual[`translation-mode:${friendlyLanguage}`]; const isMemorey = CACHE[tempState.uri]?.[targetConvert]; if (CONFIG.visual.translate && defaultLanguage && !isMemorey) { this.translateLyrics(language, this.state.currentLyrics, targetConvert).then((translated) => { const res = { [targetConvert]: translated }; // Cache translated lyrics CACHE[tempState.uri] = { ...CACHE[tempState.uri], ...res }; this.setState({ ...res }); }); } // reset and apply this.setState( { furigana: null, romaji: null, hiragana: null, katakana: null, hangul: null, romaja: null, cn: null, hk: null, tw: null, neteaseTranslation: null, ...tempState, ...translationOverrides, language: defaultLanguage, }, () => { this.currentMusixmatchLanguage = CONFIG.visual["musixmatch-translation-language"]; if (shouldRefreshMusixmatchTranslation) { this.refreshMusixmatchTranslation(); } } ); return; } this.setState({ ...tempState, ...translationOverrides }, () => { this.currentMusixmatchLanguage = CONFIG.visual["musixmatch-translation-language"]; if (shouldRefreshMusixmatchTranslation) { this.refreshMusixmatchTranslation(); } }); } lyricsSource(lyricsState, mode) { if (!lyricsState) return; const lang = this.provideLanguageCode(this.state.currentLyrics); const friendlyLanguage = lang && new Intl.DisplayNames(["en"], { type: "language" }).of(lang.split("-")[0])?.toLowerCase(); if (!this.displayMode) { this.displayMode = CONFIG.visual[`translation-mode:${friendlyLanguage}`]; } // get original Lyrics const lyrics = lyricsState[CONFIG.modes[mode]]; const translationSourceConfig = resolveTranslationSource(CONFIG.visual["translate:translated-lyrics-source"]); if (translationSourceConfig.language) { const translationLanguageKey = `${APP_NAME}:visual:musixmatch-translation-language`; const storedLanguage = localStorage.getItem(translationLanguageKey); if (storedLanguage !== translationSourceConfig.language) { localStorage.setItem(translationLanguageKey, translationSourceConfig.language); } if (CONFIG.visual["musixmatch-translation-language"] !== translationSourceConfig.language) { CONFIG.visual["musixmatch-translation-language"] = translationSourceConfig.language; } } if (CONFIG.visual.translate) { this.state.currentLyrics = lyricsState[CONFIG.visual[`translation-mode:${friendlyLanguage}`]] ?? lyrics; } else { this.state.currentLyrics = lyricsState[translationSourceConfig.key] ?? lyrics; } // Convert Mode re-fresh if ( this.translate !== CONFIG.visual.translate || this.languageOverride !== CONFIG.visual["translate:detect-language-override"] || this.displayMode !== CONFIG.visual[`translation-mode:${friendlyLanguage}`] ) { this.translate = CONFIG.visual.translate; this.languageOverride = CONFIG.visual["translate:detect-language-override"]; this.displayMode = CONFIG.visual[`translation-mode:${friendlyLanguage}`]; if (CONFIG.visual.translate) { const targetConvert = CONFIG.visual[`translation-mode:${friendlyLanguage}`]; const isCached = CACHE[lyricsState.uri]?.[targetConvert]; if (!isCached) { this.translateLyrics(lang, lyrics, targetConvert).then((translated) => { const res = { [targetConvert]: translated }; // Cache translated lyrics CACHE[lyricsState.uri] = { ...CACHE[lyricsState.uri], ...res }; this.setState({ ...this.state, ...res }); }); } } else { const resetCache = { furigana: null, romaji: null, hiragana: null, katakana: null, hangul: null, romaja: null, cn: null, hk: null, tw: null }; CACHE[lyricsState.uri] = { ...CACHE[lyricsState.uri], ...resetCache }; } } } provideLanguageCode(lyrics) { if (!lyrics) return; if (CONFIG.visual["translate:detect-language-override"] !== "off") { return CONFIG.visual["translate:detect-language-override"]; } if (this.state.language) { return this.state.language; } return Utils.detectLanguage(lyrics); } async translateLyrics(language, lyrics, targetConvert) { if (!language) return; Spicetify.showNotification("Converting...", false, 1000); if (!this.translator) { this.translator = new Translator(language); } await this.translator.awaitFinished(language); let result; try { if (language === "ja") { // Japanese const map = { romaji: { target: "romaji", mode: "spaced" }, furigana: { target: "hiragana", mode: "furigana" }, hiragana: { target: "hiragana", mode: "normal" }, katakana: { target: "katakana", mode: "normal" }, }; result = await Promise.all( lyrics.map(async (lyric) => await this.translator.romajifyText(lyric.text, map[targetConvert].target, map[targetConvert].mode)) ); } else if (language === "ko") { // Korean result = await Promise.all(lyrics.map(async (lyric) => await this.translator.convertToRomaja(lyric.text, "romaji"))); } else if (language === "zh-hans") { // Chinese (Simplified) const map = { cn: { from: "cn", target: "cn" }, tw: { from: "cn", target: "tw" }, hk: { from: "cn", target: "hk" }, }; // prevent conversion between the same language. if (targetConvert === "cn") { Spicetify.showNotification("No conversion is needed", false, 1000); return lyrics; } result = await Promise.all( lyrics.map(async (lyric) => await this.translator.convertChinese(lyric.text, map[targetConvert].from, map[targetConvert].target)) ); } else if (language === "zh-hant") { // Chinese (Traditional) const map = { cn: { from: "t", target: "cn" }, hk: { from: "t", target: "hk" }, tw: { from: "t", target: "tw" }, }; // prevent conversion between the same language. if (targetConvert === "tw") { Spicetify.showNotification("No conversion is needed", false, 1000); return lyrics; } result = await Promise.all( lyrics.map(async (lyric) => await this.translator.convertChinese(lyric.text, map[targetConvert].from, map[targetConvert].target)) ); } const res = Utils.processTranslatedLyrics(result, lyrics); Spicetify.showNotification("Converting...", false, 0); return res; } catch (error) { Spicetify.showNotification("Convert Error!", true); console.error(error); } } resetDelay() { CONFIG.visual.delay = Number(localStorage.getItem(`lyrics-delay:${Spicetify.Player.data.item.uri}`)) || 0; } async onVersionChange(items, index) { if (this.state.mode === GENIUS) { this.setState({ ...emptyLine, genius2: this.state.genius2, isLoading: true, }); const lyrics = await ProviderGenius.fetchLyricsVersion(items, index); this.setState({ genius: lyrics, versionIndex: index, isLoading: false, }); } } async onVersionChange2(items, index) { if (this.state.mode === GENIUS) { this.setState({ ...emptyLine, genius: this.state.genius, isLoading: true, }); const lyrics = await ProviderGenius.fetchLyricsVersion(items, index); this.setState({ genius2: lyrics, versionIndex2: index, isLoading: false, }); } } saveLocalLyrics(uri, lyrics) { if (lyrics.genius) { lyrics.unsynced = lyrics.genius.split("
").map((lyc) => { return { text: lyc.replace(/<[^>]*>/g, ""), }; }); lyrics.genius = null; } const localLyrics = JSON.parse(localStorage.getItem(`${APP_NAME}:local-lyrics`)) || {}; localLyrics[uri] = lyrics; localStorage.setItem(`${APP_NAME}:local-lyrics`, JSON.stringify(localLyrics)); this.setState({ isCached: true }); } deleteLocalLyrics(uri) { const localLyrics = JSON.parse(localStorage.getItem(`${APP_NAME}:local-lyrics`)) || {}; delete localLyrics[uri]; localStorage.setItem(`${APP_NAME}:local-lyrics`, JSON.stringify(localLyrics)); console.log(localLyrics); this.setState({ isCached: false }); } lyricsSaved(uri) { const localLyrics = JSON.parse(localStorage.getItem(`${APP_NAME}:local-lyrics`)) || {}; return !!localLyrics[uri]; } processLyricsFromFile(event) { const file = event.target.files; if (!file.length) return; const reader = new FileReader(); if (file[0].size > 1024 * 1024) { Spicetify.showNotification("File too large", true); return; } reader.onload = (e) => { try { const localLyrics = Utils.parseLocalLyrics(e.target.result); const parsedKeys = Object.keys(localLyrics) .filter((key) => localLyrics[key]) .map((key) => key[0].toUpperCase() + key.slice(1)) .map((key) => `${key}`); if (!parsedKeys.length) { Spicetify.showNotification("Nothing to load", true); return; } this.setState({ ...localLyrics, provider: "local" }); CACHE[this.currentTrackUri] = { ...localLyrics, provider: "local", uri: this.currentTrackUri }; this.saveLocalLyrics(this.currentTrackUri, localLyrics); Spicetify.showNotification(`Loaded ${parsedKeys.join(", ")} lyrics from file`); } catch (e) { console.error(e); Spicetify.showNotification("Failed to load lyrics", true); } }; reader.onerror = (e) => { console.error(e); Spicetify.showNotification("Failed to read file", true); }; reader.readAsText(file[0]); event.target.value = ""; } initMoustrap() { if (!this.mousetrap && Spicetify.Mousetrap) { this.mousetrap = new Spicetify.Mousetrap(); } } componentDidMount() { this.onQueueChange = async ({ data: queue }) => { this.state.explicitMode = this.state.lockMode; this.currentTrackUri = queue.current.uri; this.fetchLyrics(queue.current, this.state.explicitMode); this.viewPort.scrollTo(0, 0); // Fetch next track const nextTrack = queue.queued?.[0] || queue.nextUp?.[0]; const nextInfo = this.infoFromTrack(nextTrack); // Debounce next track fetch if (!nextInfo || nextInfo.uri === this.nextTrackUri) return; this.nextTrackUri = nextInfo.uri; this.tryServices(nextInfo, this.state.explicitMode).then((resp) => { if (resp.provider) { // Cache lyrics CACHE[resp.uri] = resp; } }); }; if (Spicetify.Player?.data?.item) { this.state.explicitMode = this.state.lockMode; this.currentTrackUri = Spicetify.Player.data.item.uri; this.fetchLyrics(Spicetify.Player.data.item, this.state.explicitMode); } this.updateVisualOnConfigChange(); Utils.addQueueListener(this.onQueueChange); lyricContainerUpdate = () => { this.reRenderLyricsPage = !this.reRenderLyricsPage; this.updateVisualOnConfigChange(); this.forceUpdate(); if (this.currentMusixmatchLanguage !== CONFIG.visual["musixmatch-translation-language"]) { this.currentMusixmatchLanguage = CONFIG.visual["musixmatch-translation-language"]; this.refreshMusixmatchTranslation(); } }; refreshMusixmatchTranslation = this.refreshMusixmatchTranslation.bind(this); reloadLyrics = () => { CACHE = {}; this.updateVisualOnConfigChange(); this.forceUpdate(); this.fetchLyrics(Spicetify.Player.data.item, this.state.explicitMode, true); }; this.viewPort = document.querySelector(".Root__main-view .os-viewport") ?? document.querySelector(".Root__main-view .main-view-container__scroll-node"); this.configButton = new Spicetify.Menu.Item("Lyrics Plus config", false, openConfig, "lyrics"); this.configButton.register(); this.onFontSizeChange = (event) => { if (!event.ctrlKey) return; const dir = event.deltaY < 0 ? 1 : -1; let temp = CONFIG.visual["font-size"] + dir * fontSizeLimit.step; if (temp < fontSizeLimit.min) { temp = fontSizeLimit.min; } else if (temp > fontSizeLimit.max) { temp = fontSizeLimit.max; } CONFIG.visual["font-size"] = temp; localStorage.setItem("lyrics-plus:visual:font-size", temp); lyricContainerUpdate(); }; this.toggleFullscreen = () => { const isEnabled = !this.state.isFullscreen; if (isEnabled) { document.body.append(this.fullscreenContainer); document.documentElement.requestFullscreen(); this.mousetrap.bind("esc", this.toggleFullscreen); } else { this.fullscreenContainer.remove(); document.exitFullscreen(); this.mousetrap.unbind("esc"); } this.setState({ isFullscreen: isEnabled, }); }; this.mousetrap.reset(); this.mousetrap.bind(CONFIG.visual["fullscreen-key"], this.toggleFullscreen); window.addEventListener("fad-request", lyricContainerUpdate); } componentWillUnmount() { Utils.removeQueueListener(this.onQueueChange); this.configButton.deregister(); this.mousetrap.reset(); window.removeEventListener("fad-request", lyricContainerUpdate); refreshMusixmatchTranslation = null; } updateVisualOnConfigChange() { this.availableModes = CONFIG.modes.filter((_, id) => { return Object.values(CONFIG.providers).some((p) => p.on && p.modes.includes(id)); }); if (!CONFIG.visual.colorful) { this.styleVariables = { "--lyrics-color-active": CONFIG.visual["active-color"], "--lyrics-color-inactive": CONFIG.visual["inactive-color"], "--lyrics-color-background": CONFIG.visual["background-color"], "--lyrics-highlight-background": CONFIG.visual["highlight-color"], "--lyrics-background-noise": CONFIG.visual.noise ? "var(--background-noise)" : "unset", }; } this.styleVariables = { ...this.styleVariables, "--lyrics-align-text": CONFIG.visual.alignment, "--lyrics-font-size": `${CONFIG.visual["font-size"]}px`, "--animation-tempo": this.state.tempo, }; this.mousetrap.reset(); this.mousetrap.bind(CONFIG.visual["fullscreen-key"], this.toggleFullscreen); } render() { const fadLyricsContainer = document.getElementById("fad-lyrics-plus-container"); this.state.isFADMode = !!fadLyricsContainer; if (this.state.isFADMode) { // Text colors will be set by FAD extension this.styleVariables = {}; } else if (CONFIG.visual.colorful) { this.styleVariables = { "--lyrics-color-active": "white", "--lyrics-color-inactive": this.state.colors.inactive, "--lyrics-color-background": this.state.colors.background || "transparent", "--lyrics-highlight-background": this.state.colors.inactive, "--lyrics-background-noise": CONFIG.visual.noise ? "var(--background-noise)" : "unset", }; } this.styleVariables = { ...this.styleVariables, "--lyrics-align-text": CONFIG.visual.alignment, "--lyrics-font-size": `${CONFIG.visual["font-size"]}px`, "--animation-tempo": this.state.tempo, }; let mode = -1; if (this.state.explicitMode !== -1) { mode = this.state.explicitMode; } else if (this.state.lockMode !== -1) { mode = this.state.lockMode; } else { // Auto switch if (this.state.karaoke) { mode = KARAOKE; } else if (this.state.synced) { mode = SYNCED; } else if (this.state.unsynced) { mode = UNSYNCED; } else if (this.state.genius) { mode = GENIUS; } } let activeItem; let showTranslationButton; this.lyricsSource(this.state, mode); const lang = this.provideLanguageCode(this.state.currentLyrics); const friendlyLanguage = lang && new Intl.DisplayNames(["en"], { type: "language" }).of(lang.split("-")[0])?.toLowerCase(); const hasMusixmatchLanguages = Array.isArray(this.state.musixmatchAvailableTranslations) && this.state.musixmatchAvailableTranslations.length > 0; const hasTranslation = this.state.neteaseTranslation !== null || this.state.musixmatchTranslation !== null || hasMusixmatchLanguages; const hasPerformer = !!this.state.currentLyrics?.some((line) => line.performer); if (mode !== -1) { showTranslationButton = (friendlyLanguage || hasTranslation) && (mode === SYNCED || mode === UNSYNCED); if (mode === KARAOKE && this.state.karaoke) { activeItem = react.createElement(CONFIG.visual["synced-compact"] ? SyncedLyricsPage : SyncedExpandedLyricsPage, { isKara: true, trackUri: this.state.uri, lyrics: this.state.karaoke, provider: this.state.provider, copyright: this.state.copyright, reRenderLyricsPage: this.reRenderLyricsPage, }); } else if (mode === SYNCED && this.state.synced) { activeItem = react.createElement(CONFIG.visual["synced-compact"] ? SyncedLyricsPage : SyncedExpandedLyricsPage, { trackUri: this.state.uri, lyrics: this.state.currentLyrics, provider: this.state.provider, copyright: this.state.copyright, reRenderLyricsPage: this.reRenderLyricsPage, }); } else if (mode === UNSYNCED && this.state.unsynced) { activeItem = react.createElement(UnsyncedLyricsPage, { trackUri: this.state.uri, lyrics: this.state.currentLyrics, provider: this.state.provider, copyright: this.state.copyright, reRenderLyricsPage: this.reRenderLyricsPage, }); } else if (mode === GENIUS && this.state.genius) { activeItem = react.createElement(GeniusPage, { isSplitted: CONFIG.visual["dual-genius"], trackUri: this.state.uri, lyrics: this.state.genius, provider: this.state.provider, copyright: this.state.copyright, versions: this.state.versions, versionIndex: this.state.versionIndex, onVersionChange: this.onVersionChange.bind(this), lyrics2: this.state.genius2, versionIndex2: this.state.versionIndex2, onVersionChange2: this.onVersionChange2.bind(this), reRenderLyricsPage: this.reRenderLyricsPage, }); } } if (!activeItem) { activeItem = react.createElement( "div", { className: "lyrics-lyricsContainer-LyricsUnavailablePage", }, react.createElement( "span", { className: "lyrics-lyricsContainer-LyricsUnavailableMessage", }, this.state.isLoading ? LoadingIcon : "(• _ • )" ) ); } this.state.mode = mode; const out = react.createElement( "div", { className: `lyrics-lyricsContainer-LyricsContainer${CONFIG.visual["fade-blur"] ? " blur-enabled" : ""}${ fadLyricsContainer ? " fad-enabled" : "" }`, style: this.styleVariables, ref: (el) => { if (!el) return; el.onmousewheel = this.onFontSizeChange; }, }, react.createElement("div", { className: "lyrics-lyricsContainer-LyricsBackground", }), react.createElement( "div", { className: "lyrics-config-button-container", }, showTranslationButton && react.createElement(TranslationMenu, { friendlyLanguage, hasTranslation: { musixmatch: this.state.musixmatchTranslation !== null, netease: this.state.neteaseTranslation !== null, }, musixmatchLanguages: this.state.musixmatchAvailableTranslations || [], musixmatchSelectedLanguage: this.state.musixmatchTranslationLanguage || CONFIG.visual["musixmatch-translation-language"], }), react.createElement(AdjustmentsMenu, { mode, hasPerformer }), react.createElement( Spicetify.ReactComponent.TooltipWrapper, { label: this.state.isCached ? "Lyrics cached" : "Cache lyrics", }, react.createElement( "button", { className: "lyrics-config-button", onClick: () => { const { synced, unsynced, karaoke, genius } = this.state; if (!synced && !unsynced && !karaoke && !genius) { Spicetify.showNotification("No lyrics to cache", true); return; } if (this.state.isCached) { this.deleteLocalLyrics(this.currentTrackUri); Spicetify.showNotification("Delete lyrics cache"); } else { this.saveLocalLyrics(this.currentTrackUri, { synced, unsynced, karaoke, genius }); Spicetify.showNotification("Lyrics cached"); } }, }, react.createElement("svg", { width: 16, height: 16, viewBox: "0 0 16 16", fill: "currentColor", dangerouslySetInnerHTML: { __html: Spicetify.SVGIcons[this.state.isCached ? "downloaded" : "download"], }, }) ) ), react.createElement( Spicetify.ReactComponent.TooltipWrapper, { label: "Load lyrics from file", }, react.createElement( "button", { className: "lyrics-config-button", onClick: () => { document.getElementById("lyrics-file-input").click(); }, }, react.createElement("input", { type: "file", id: "lyrics-file-input", accept: ".lrc,.txt", onChange: this.processLyricsFromFile.bind(this), style: { display: "none", }, }), react.createElement("svg", { width: 16, height: 16, viewBox: "0 0 16 16", fill: "currentColor", dangerouslySetInnerHTML: { __html: Spicetify.SVGIcons["plus-alt"], }, }) ) ) ), activeItem, !!document.querySelector(".main-topBar-topbarContentWrapper") && react.createElement(TopBarContent, { links: this.availableModes, activeLink: CONFIG.modes[mode], lockLink: CONFIG.modes[this.state.lockMode], switchCallback: (label) => { const mode = CONFIG.modes.findIndex((a) => a === label); if (mode !== this.state.mode) { // If explicitMode is not set, moving the topBar will apply the default mode value for the selected song. const info = this.infoFromTrack(Spicetify.Player.data.item); if (info?.uri && CACHE[info?.uri]) { CACHE[info.uri].mode = mode; } this.setState({ explicitMode: mode }); this.state.provider !== "local" && this.fetchLyrics(Spicetify.Player.data.item, mode); } }, lockCallback: (label) => { let mode = CONFIG.modes.findIndex((a) => a === label); if (mode === this.state.lockMode) { mode = -1; } this.setState({ explicitMode: mode, lockMode: mode }); this.fetchLyrics(Spicetify.Player.data.item, mode); CONFIG.locked = mode; localStorage.setItem("lyrics-plus:lock-mode", mode); }, }) ); if (this.state.isFullscreen) return Spicetify.ReactDOM.createPortal(out, this.fullscreenContainer); if (fadLyricsContainer) return Spicetify.ReactDOM.createPortal(out, fadLyricsContainer); return out; } } ================================================ FILE: CustomApps/lyrics-plus/manifest.json ================================================ { "name": { "ms": "Lyrics", "gu": "Lyrics", "ko": "Lyrics", "pa-IN": "Lyrics", "az": "Lyrics", "ru": "Текст", "uk": "Lyrics", "nb": "Lyrics", "sv": "Låttext", "sw": "Lyrics", "ur": "Lyrics", "bho": "Lyrics", "pa-PK": "Lyrics", "te": "Lyrics", "ro": "Lyrics", "vi": "Lời bài hát", "am": "Lyrics", "bn": "Lyrics", "en": "Lyrics", "id": "Lirik", "bg": "Lyrics", "da": "Lyrics", "es-419": "Letras", "mr": "Lyrics", "ml": "Lyrics", "th": "เนื้อเพลง", "tr": "Şarkı Sözleri", "is": "Lyrics", "fa": "Lyrics", "or": "Lyrics", "he": "Lyrics", "hi": "Lyrics", "zh-TW": "歌詞", "sr": "Lyrics", "pt-BR": "Letra", "zu": "Lyrics", "nl": "Songteksten", "es": "Letra", "lt": "Lyrics", "ja": "歌詞", "st": "Lyrics", "it": "Lyrics", "el": "Στίχοι", "pt-PT": "Lyrics", "kn": "Lyrics", "de": "Songtext", "fr": "Paroles", "ne": "Lyrics", "ar": "الكلمات", "af": "Lyrics", "et": "Lyrics", "pl": "Tekst", "ta": "Lyrics", "sl": "Lyrics", "pk": "Lyrics", "hr": "Lyrics", "sk": "Lyrics", "fi": "Sanat", "lv": "Lyrics", "fil": "Lyrics", "fr-CA": "Paroles", "cs": "Text", "zh-CN": "歌词", "hu": "Dalszöveg" }, "icon": "", "active-icon": "", "subfiles": [ "ProviderNetease.js", "ProviderMusixmatch.js", "ProviderGenius.js", "ProviderLRCLIB.js", "Providers.js", "Pages.js", "OptionsMenu.js", "TabBar.js", "Utils.js", "Settings.js", "Translator.js" ], "subfiles_extension": ["PlaybarButton.js"] } ================================================ FILE: CustomApps/lyrics-plus/style.css ================================================ /*! * Bootstrap v3.3.7 (http://getbootstrap.com) * Copyright 2011-2016 Twitter, Inc. * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) */ /*! * Generated using the Bootstrap Customizer (http://getbootstrap.com/customize/?id=35378cd201a131f69c68a64bc4438544) * Config saved to config.json and https://gist.github.com/35378cd201a131f69c68a64bc4438544 */ @media (min-width: 768px) { .container { width: 750px; } } @media (min-width: 992px) { .container { width: 970px; } } @media (min-width: 1200px) { .container { width: 1170px; } } @media (min-width: 1500px) { .container { width: 1450px; } } .row { margin-left: -16px; margin-right: -16px; } .container:after, .row:after { clear: both; } .hide { display: none !important; } .show { display: block !important; } .hidden { display: none !important; } .lyrics-lyricsContainer-LyricsContainer { display: grid; grid-template-rows: 1fr; position: absolute; height: 100%; width: 100%; top: 0; } .lyrics-lyricsContainer-Loading { align-self: center; grid-area: 1 / 1 / -1 / -1; } .lyrics-lyricsContainer-LyricsUnavailablePage { align-items: center; color: var(--lyrics-color-inactive); display: flex; grid-area: 1 / 1 / -1 / -1; height: 100%; justify-content: center; padding: 20px; font-size: 88px; letter-spacing: 0.1em; font-weight: 700; } .lyrics-lyricsContainer-UnsyncedLyricsPage { grid-area: 1 / 1 / -1 / -1; grid-template-rows: 1fr 20px; user-select: text; text-align: var(--lyrics-align-text); } .lyrics-lyricsContainer-LyricsUnsyncedPadding { display: flex; /* 2 padding blocks & 1 line height & Provider block */ height: calc(50vh - 91px - 8px - var(--lyrics-font-size)); } .lyrics-lyricsContainer-UnsyncedLyricsPage:has(.lyrics-versionSelector, .lyrics-lyricsContainer-LyricsLine:nth-child(4)) .lyrics-lyricsContainer-LyricsUnsyncedPadding { height: 10vh; } .lyrics-lyricsContainer-SyncedLyricsPage { display: grid; grid-area: 1 / 1 / -1 / -1; grid-template-rows: 1fr 30px; overflow: hidden; text-align: var(--lyrics-align-text); user-select: text; } .lyrics-lyricsContainer-LyricsBackground { background-color: var(--lyrics-color-background); background-image: var(--lyrics-background-noise); grid-area: 1 / 1 / -1 / -1; transition: background-color 0.25s ease-out; } .lyrics-lyricsContainer-Provider { align-self: end; color: var(--lyrics-color-inactive); grid-area: 2 / 1 / -1 / -1; justify-self: stretch; height: 25px; overflow: hidden; background: linear-gradient(0deg, var(--lyrics-color-background) 30%, transparent); z-index: 1; padding: 60px 20px 30px; pointer-events: none; } .lyrics-lyricsContainer-SyncedLyrics { --lyrics-line-height: calc(4px + var(--lyrics-font-size)); grid-area: 1 / 1 / -2 / -1; height: 0; } .lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine { transform: translateY(calc(var(--position-index) * var(--lyrics-line-height) + var(--offset))); transform-origin: var(--lyrics-align-text); transition-timing-function: cubic-bezier(0, 0, 0.58, 1); transition-duration: calc(var(--animation-index) * var(--animation-tempo) + 0.1s); transition-property: transform, color, opacity; } .lyrics-lyricsContainer-LyricsContainer.blur-enabled .lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine { filter: blur(calc(var(--blur-index) * 1.5px)); } .lyrics-lyricsContainer-UnsyncedLyricsPage .lyrics-lyricsContainer-LyricsLine a { color: var(--lyrics-color-active); } .lyrics-lyricsContainer-UnsyncedLyricsPage .lyrics-lyricsContainer-LyricsLine { color: var(--lyrics-color-inactive); transition: color 0.25s cubic-bezier(0, 0, 0.58, 1); } .lyrics-lyricsContainer-UnsyncedLyricsPage .lyrics-lyricsContainer-LyricsLine-active { color: var(--lyrics-color-active); } .lyrics-lyricsContainer-UnsyncedLyricsPage .lyrics-lyricsContainer-LyricsLine:hover { color: var(--lyrics-color-active); } .lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine { color: var(--lyrics-color-inactive); } .lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine:hover { color: var(--lyrics-color-active); } .lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine.lyrics-lyricsContainer-LyricsLine-active { color: var(--lyrics-color-active); opacity: 1; transform: translateY(calc(var(--position-index) * var(--lyrics-line-height) + var(--offset))) scale(1.1); filter: none !important; } .lyrics-lyricsContainer-SyncedLyrics > .lyrics-lyricsContainer-LyricsLine-paddingLine { opacity: 0; pointer-events: none; } .lyrics-lyricsContainer-LyricsLine, .lyrics-versionSelector { margin-left: 100px; margin-right: 100px; } @media (min-width: 1024px) { .lyrics-lyricsContainer-LyricsLine, .lyrics-versionSelector { margin-left: 150px; margin-right: 150px; } } @media (min-width: 1280px) { .lyrics-lyricsContainer-LyricsLine, .lyrics-versionSelector { margin-left: 200px; margin-right: 200px; } } .lyrics-lyricsContainer-UnsyncedLyricsPage .lyrics-lyricsContainer-LyricsLine { font-size: var(--lyrics-font-size); font-weight: 700; letter-spacing: -0.04em; line-height: calc(12px + var(--lyrics-font-size)); } .lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine { font-size: var(--lyrics-font-size); font-weight: 700; letter-spacing: -0.04em; line-height: var(--lyrics-line-height); } @media (min-width: 1280px) { .lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine { font-weight: 900; } } .lyrics-tabBar-headerItem { -webkit-app-region: no-drag; display: inline-block; pointer-events: auto; } .lyrics-tabBar-headerItemLink { margin: 0 8px 0 0; } .lyrics-tabBar-active { background-color: var(--spice-tab-active); border-radius: 4px; } .lyrics-tabBar-headerItemLink { border-radius: 4px; color: var(--spice-text); display: inline-block; margin: 0 8px; padding: 8px 16px; position: relative; text-decoration: none !important; cursor: pointer; } .lyrics-tabBar-headerItemLink .main-type-mestoBold { text-transform: capitalize; } .lyrics-tabBar-headerItemLink-locked::before { content: "• "; } .lyrics-tabBar-nav { -webkit-app-region: drag; pointer-events: none; width: 100%; } .lyrics-tabBar-header { display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center; justify-content: flex-start; } .lyrics-tabBar-headerItem .optionsMenu-dropBox { color: var(--spice-text); border: 0; max-width: 150px; height: 42px; padding: 0 30px 0 12px; background-color: initial; cursor: pointer; appearance: none; } .lyrics-tabBar-headerItem .optionsMenu-dropBox svg { position: absolute; margin-left: 8px; } #lyrics-plus-config-container option { background-color: var(--spice-button); } div.lyrics-tabBar-headerItemLink { padding: 0; } .lyrics-tabBar-header button.switch { margin-inline-end: 12px; margin-inline-start: 0; } .lyrics-lyricsContainer-Karaoke-WordActive { color: var(--lyrics-color-active) !important; background-position: top left !important; } .lyrics-lyricsContainer-LyricsLine:hover .lyrics-lyricsContainer-Karaoke-Word { background-position: top left; } .lyrics-lyricsContainer-Karaoke-Word { color: var(--lyrics-color-inactive); background-image: linear-gradient( to right, var(--lyrics-color-active), var(--lyrics-color-active) 45%, var(--lyrics-color-inactive) 55%, var(--lyrics-color-inactive) ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-size: 225% 100%; background-position: top left 100%; transition-property: color, background-position; transition-duration: calc(var(--word-duration) + 0.05s); transition-timing-function: linear; } .lyrics-lyricsContainer-LyricsLine a { background-color: transparent; transition: background-color 0.25s cubic-bezier(0, 0, 0, 1); } .lyrics-lyricsContainer-LyricsLine a.fetched { background-color: var(--lyrics-highlight-background); } .lyrics-lyricsContainer-LyricsLine a, .lyrics-lyricsContainer-LyricsLine a:hover { text-decoration: none !important; } .lyrics-lyricsContainer-LyricsLine a:hover { border-bottom: 2px solid var(--lyrics-color-active); } .lyrics-Genius-noteTextContainer { font-size: 18px; font-weight: 400; letter-spacing: normal; line-height: 24px; text-transform: none; padding: 25px; background-color: var(--lyrics-color-active); border-radius: 3px; color: var(--lyrics-highlight-background); box-shadow: 0 10px 15px 5px rgb(0, 0, 0, 0.2); cursor: default; text-align: left; } .lyrics-Genius-divider { /* border-bottom: 3px solid var(--lyrics-color-active); */ line-height: 0; margin-left: var(--link-left); } .lyrics-Searchbar { position: sticky; width: 300px; height: 40px; bottom: 10px; display: flex; background-color: var(--lyrics-color-active) !important; color: var(--lyrics-highlight-background); margin-left: 10px; border-radius: 3px; box-shadow: 0 10px 15px 5px rgb(0, 0, 0, 0.2); } .lyrics-Searchbar input { width: 300px; height: 40px; bottom: 10px; border: 0; color: var(--lyrics-highlight-background) !important; padding: 0 36px; } .lyrics-Searchbar svg { position: absolute; left: 0; height: 40px; margin-left: 10px; } .lyrics-Searchbar span { position: relative; right: 0; line-height: 40px; margin-right: 10px; font-weight: 400; font-size: 16px; letter-spacing: 0.2em; } .lyrics-Searchbar-highlight { position: fixed; width: 100%; height: var(--search-highlight-height); left: 0; top: var(--search-highlight-top); background-color: var(--lyrics-highlight-background); opacity: 0.5; pointer-events: none; } .lyrics-versionSelector { max-width: 500px; border-radius: 4px; display: inline-block; position: relative; cursor: pointer; box-shadow: 0 10px 15px 5px rgb(0, 0, 0, 0.2); background-color: var(--lyrics-highlight-background); margin-bottom: 75px; } .lyrics-versionSelector select { border: 0; border-radius: 4px; max-width: 500px; height: 42px; padding: 0 30px 0 12px; cursor: pointer; appearance: none; font-size: 18px; background-color: var(--lyrics-color-active); color: var(--lyrics-highlight-background); } .lyrics-versionSelector option { background-color: var(--lyrics-color-active); } .lyrics-versionSelector svg { position: absolute; height: 42px; right: 10px; pointer-events: none; fill: var(--lyrics-highlight-background); } /** Setting menu */ .lyrics-tooltip-wrapper .setting-row::after, #lyrics-plus-config-container .setting-row::after { content: ""; display: table; clear: both; } .lyrics-tooltip-wrapper .setting-row .col, #lyrics-plus-config-container .setting-row .col { padding: 16px 0 4px; align-items: center; } .lyrics-tooltip-wrapper .setting-row .col.description, #lyrics-plus-config-container .setting-row .col.description { float: left; padding-right: 15px; cursor: default; } .lyrics-tooltip-wrapper .setting-row .col.action, #lyrics-plus-config-container .setting-row .col.action { float: right; display: flex; justify-content: flex-end; align-items: center; } .lyrics-tooltip-wrapper button.switch, #lyrics-plus-config-container button.switch { align-items: center; border: 0px; border-radius: 50%; background-color: rgba(var(--spice-rgb-shadow), 0.7); color: var(--spice-text); cursor: pointer; margin-inline-start: 12px; padding: 8px; width: 32px; height: 32px; } .lyrics-tooltip-wrapper button.switch.disabled, .lyrics-tooltip-wrapper button.switch[disabled], #lyrics-plus-config-container button.switch.disabled, #lyrics-plus-config-container button.switch[disabled] { color: rgba(var(--spice-rgb-text), 0.3); } .lyrics-tooltip-wrapper button.switch.small, #lyrics-plus-config-container button.switch.small { width: 22px; height: 22px; padding: 3px; } .lyrics-tooltip-wrapper input, #lyrics-plus-config-container input { width: 100%; margin-top: 10px; padding: 0 5px; height: 32px; border: 0; color: var(--spice-text); background-color: initial; border-bottom: 1px solid var(--spice-text); } .lyrics-tooltip-wrapper .col.action .adjust-value, #lyrics-plus-config-container .col.action .adjust-value { margin-inline-start: 12px; min-width: 22px; text-align: center; } .lyrics-tooltip-wrapper .col.action span, #lyrics-plus-config-container .col.action span { font-size: 14px; opacity: 0.8; } .lyrics-tooltip-wrapper .col.action .btn, #lyrics-plus-config-container .col.action .btn { font-weight: 700; background-color: transparent; border-radius: 500px; transition-duration: 33ms; transition-property: background-color, border-color, color, box-shadow, filter, transform; padding-inline: 15px; border: 1px solid #727272; color: var(--spice-text); min-block-size: 32px; cursor: pointer; } .lyrics-tooltip-wrapper .col.action .btn:hover, #lyrics-plus-config-container .col.action .btn:hover { transform: scale(1.04); border-color: var(--spice-text); } .lyrics-tooltip-wrapper .col.action .btn:disabled, #lyrics-plus-config-container .col.action .btn:disabled { opacity: 0.5; cursor: not-allowed; } .lyrics-tooltip-wrapper .col.action .main-dropDown-dropDown, .lyrics-tooltip-wrapper .col.action input, #lyrics-plus-config-container .col.action .main-dropDown-dropDown, #lyrics-plus-config-container .col.action input { width: 150px; } #lyrics-fullscreen-container { position: fixed; width: 100vw; height: 100vh; cursor: default; left: 0; top: 0; } #lyrics-fullscreen-container .lyrics-lyricsContainer-LyricsContainer { height: 100vh; margin-bottom: 0; margin-top: 0; overflow-y: auto; } #lyrics-fullscreen-container .lyrics-lyricsContainer-LyricsContainer::-webkit-scrollbar { background-color: var(--lyrics-color-background); } .lyrics-lyricsContainer-LyricsContainer.fad-enabled { height: 100vh; margin-top: 0; margin-bottom: 0; overflow-y: scroll; } .lyrics-lyricsContainer-LyricsContainer.fad-enabled .lyrics-lyricsContainer-LyricsLine { margin-left: 100px; margin-right: 100px; } .lyrics-lyricsContainer-LyricsContainer.fad-enabled .lyrics-lyricsContainer-LyricsBackground, .lyrics-lyricsContainer-LyricsContainer.fad-enabled .lyrics-lyricsContainer-Provider { display: none; } .lyrics-lyricsContainer-LyricsContainer.fad-enabled .lyrics-lyricsContainer-SyncedLyricsPage { width: 100%; } .lyrics-lyricsContainer-LyricsContainer.fad-enabled .lyrics-config-button-container { opacity: 0; transition: opacity 0.2s cubic-bezier(0, 0, 0.58, 1); } .lyrics-lyricsContainer-LyricsContainer.fad-enabled:hover .lyrics-config-button-container { opacity: 1; } .lyrics-idling-indicator { display: inline-block; opacity: 1; transition: opacity 0.2s cubic-bezier(0, 0, 0.58, 1); } .lyrics-idling-indicator-hidden { opacity: 0; } .lyrics-idling-indicator__circle { background-color: var(--lyrics-color-active); border-radius: 50%; display: inline-block; opacity: 0.5; margin-right: calc(var(--lyrics-font-size) / 4); transform-origin: center; transition-timing-function: linear; transition-duration: var(--indicator-delay); transition-property: transform, opacity; height: var(--lyrics-font-size); width: var(--lyrics-font-size); transform: scale(0.5); } .lyrics-idling-indicator__circle.active { opacity: 1; transform: scale(0.7); } .lyrics-config-button-container { -webkit-margin-end: 32px; -webkit-box-pack: end; pointer-events: none; bottom: 32px; display: flex; justify-content: flex-end; margin: -52px 0 0; margin-inline-end: 32px; position: sticky; z-index: 2; } .lyrics-config-button-container > * { pointer-events: auto; } @-webkit-keyframes spin { 0% { -webkit-transform: rotate(0deg); } 100% { -webkit-transform: rotate(360deg); } } .lyrics-config-button { align-items: center; background-color: rgba(0, 0, 0, 0.5); border: 0; margin: 5px; border-radius: 4px; color: #eee; cursor: pointer; display: flex; gap: 8px; justify-content: center; padding: 12px; height: 40px; width: 40px; } .lyrics-config-button-container .main-contextMenu-menu { color: var(--spice-text); padding: 12px 12px 6px; } .lyrics-lyricsContainer-UnsyncedLyricsPage .split { display: flex; } .lyrics-lyricsContainer-UnsyncedLyricsPage .split > div { flex: 50%; } .lyrics-lyricsContainer-UnsyncedLyricsPage .split > div > div:not(.lyrics-versionSelector) { margin-left: 0; margin-right: 0; } .split .lyrics-lyricsContainer-LyricsLine { padding-left: 50px; padding-right: 50px; } .split .lyrics-versionSelector { margin-right: 50px; margin-left: 50px; } .split .lyrics-versionSelector select { width: 100%; } .main-content-view { height: 100%; } @media (min-width: 1024px) { .split .lyrics-lyricsContainer-LyricsLine { padding-left: 75px; padding-right: 75px; } .split .lyrics-versionSelector { margin-right: 75px; margin-left: 75px; } } @media (min-width: 1280px) { .split .lyrics-lyricsContainer-LyricsLine { padding-left: 100px; padding-right: 100px; } .split .lyrics-versionSelector { margin-right: 100px; margin-left: 100px; } } .lyrics-lyricsContainer-Performer { display: block; font-size: 0.6em; opacity: 0.7; line-height: 1.2em; /* color: var(--lyrics-color-inactive); */ } ================================================ FILE: CustomApps/new-releases/Card.js ================================================ function DraggableComponent({ uri, title, children }) { const dragHandler = Spicetify.ReactHook.DragHandler?.([uri], title); return dragHandler ? react.cloneElement(children, { onDragStart: dragHandler, draggable: "true", }) : children; } class Card extends react.Component { constructor(props) { super(props); Object.assign(this, props); this.href = URI.fromString(this.uri).toURLPath(true); this.artistHref = URI.fromString(this.artist.uri).toURLPath(true); const uriType = Spicetify.URI.fromString(this.uri)?.type; switch (uriType) { case Spicetify.URI.Type.ALBUM: case Spicetify.URI.Type.TRACK: this.menuType = Spicetify.ReactComponent.AlbumMenu; break; } this.menuType = this.menuType || "div"; } play(event) { Spicetify.Player.playUri(this.uri, this.context); event.stopPropagation(); } closeButtonClicked(event) { event.stopPropagation(); removeCards(this.props.uri); Spicetify.Snackbar.enqueueCustomSnackbar ? Spicetify.Snackbar.enqueueCustomSnackbar("dismissed-release", { keyPrefix: "dismissed-release", children: Spicetify.ReactComponent.Snackbar.wrapper({ children: Spicetify.ReactComponent.Snackbar.simpleLayout({ leading: Spicetify.ReactComponent.Snackbar.styledImage({ src: this.props.imageURL, imageHeight: "24px", imageWidth: "24px", }), center: Spicetify.React.createElement("div", { dangerouslySetInnerHTML: { __html: `Dismissed ${this.title}.`, }, }), trailing: Spicetify.ReactComponent.Snackbar.ctaText({ ctaText: "Undo", onCtaClick: () => removeCards(this.props.uri, "undo"), }), }), }), }) : Spicetify.showNotification(`Dismissed ${this.title} from
${this.artist.name}`); } render() { const detail = []; this.visual.type && detail.push(this.type); if (this.visual.count && this.trackCount) { detail.push(Spicetify.Locale.get("tracklist-header.songs-counter", this.trackCount)); } return react.createElement( Spicetify.ReactComponent.RightClickMenu || "div", { menu: react.createElement(this.menuType, { uri: this.uri }), }, react.createElement( "div", { className: "main-card-card", onClick: (event) => { History.push(this.href); event.preventDefault(); }, }, react.createElement( DraggableComponent, { uri: this.uri, title: this.title, }, react.createElement( "div", { className: "main-card-draggable", }, react.createElement( "div", { className: "main-card-imageContainer", }, react.createElement( "div", { className: "main-cardImage-imageWrapper", }, react.createElement( "div", {}, react.createElement("img", { "aria-hidden": "false", draggable: "false", loading: "lazy", src: this.imageURL, className: "main-image-image main-cardImage-image", }) ) ), react.createElement( "div", { className: "main-card-PlayButtonContainer", }, react.createElement( "div", { className: "main-playButton-PlayButton main-playButton-primary", "aria-label": Spicetify.Locale.get("play"), style: { "--size": "40px" }, onClick: this.play.bind(this), }, react.createElement( "button", null, react.createElement( "span", null, react.createElement( "svg", { height: "24", role: "img", width: "24", viewBox: "0 0 24 24", "aria-hidden": "true", }, react.createElement("polygon", { points: "21.57 12 5.98 3 5.98 21 21.57 12", fill: "currentColor", }) ) ) ) ) ), react.createElement( Spicetify.ReactComponent.TooltipWrapper, { label: "Dismiss" }, react.createElement( "button", { className: "main-card-closeButton", onClick: this.closeButtonClicked.bind(this), }, react.createElement( "svg", { width: "16", height: "16", viewBox: "0 0 16 16", xmlns: "http://www.w3.org/2000/svg", className: "main-card-closeButton-svg", }, react.createElement("path", { 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", fill: "var(--spice-text)", fillRule: "evenodd", }) ) ) ) ), react.createElement( "div", { className: "main-card-cardMetadata", }, react.createElement( "a", { draggable: "false", title: this.title, className: "main-cardHeader-link", dir: "auto", href: this.href, }, react.createElement( "div", { className: "main-cardHeader-text main-type-balladBold", }, this.title ) ), detail.length > 0 && react.createElement( "div", { className: "main-cardSubHeader-root main-type-mestoBold new-releases-cardSubHeader", }, react.createElement("span", null, detail.join(" • ")) ), react.createElement( DraggableComponent, { uri: this.artist.uri, title: this.artist.name, }, react.createElement( "a", { className: "main-cardSubHeader-root main-type-mesto new-releases-cardSubHeader", href: this.artistHref, onClick: (event) => { History.push(this.artistHref); event.stopPropagation(); event.preventDefault(); }, }, react.createElement("span", null, this.artist.name) ) ) ), react.createElement("div", { className: "main-card-cardLink", }) ) ) ) ); } } ================================================ FILE: CustomApps/new-releases/Icons.js ================================================ const LoadingIcon = react.createElement( "svg", { width: "100px", height: "100px", viewBox: "0 0 100 100", preserveAspectRatio: "xMidYMid", }, react.createElement( "circle", { cx: "50", cy: "50", r: "0", fill: "none", stroke: "currentColor", "stroke-width": "2", }, react.createElement("animate", { attributeName: "r", repeatCount: "indefinite", dur: "1s", values: "0;40", keyTimes: "0;1", keySplines: "0 0.2 0.8 1", calcMode: "spline", begin: "0s", }), react.createElement("animate", { attributeName: "opacity", repeatCount: "indefinite", dur: "1s", values: "1;0", keyTimes: "0;1", keySplines: "0.2 0 0.8 1", calcMode: "spline", begin: "0s", }) ), react.createElement( "circle", { cx: "50", cy: "50", r: "0", fill: "none", stroke: "currentColor", "stroke-width": "2", }, react.createElement("animate", { attributeName: "r", repeatCount: "indefinite", dur: "1s", values: "0;40", keyTimes: "0;1", keySplines: "0 0.2 0.8 1", calcMode: "spline", begin: "-0.5s", }), react.createElement("animate", { attributeName: "opacity", repeatCount: "indefinite", dur: "1s", values: "1;0", keyTimes: "0;1", keySplines: "0.2 0 0.8 1", calcMode: "spline", begin: "-0.5s", }) ) ); class LoadMoreIcon extends react.Component { render() { return react.createElement( "div", { onClick: this.props.onClick, }, react.createElement( "p", { style: { fontSize: 100, lineHeight: "65px", }, }, "»" ), react.createElement( "span", { style: { fontSize: 20, }, }, "Load more" ) ); } } ================================================ FILE: CustomApps/new-releases/Settings.js ================================================ const ButtonSVG = ({ icon, active = true, onClick }) => { return react.createElement( "button", { className: `switch${active ? "" : " disabled"}`, onClick, }, react.createElement("svg", { width: 16, height: 16, viewBox: "0 0 16 16", fill: "currentColor", dangerouslySetInnerHTML: { __html: icon, }, }) ); }; const ButtonText = ({ text, active = true, onClick }) => { return react.createElement( "button", { className: `text${active ? "" : " disabled"}`, onClick, }, text ); }; const ConfigSlider = ({ name, defaultValue, onChange = () => {} }) => { const [active, setActive] = useState(defaultValue); const toggleState = useCallback(() => { const state = !active; setActive(state); onChange(state); }, [active]); return react.createElement( "div", { className: "setting-row", }, react.createElement( "label", { className: "col description", }, name ), react.createElement( "div", { className: "col action", }, react.createElement(ButtonSVG, { icon: Spicetify.SVGIcons.check, active, onClick: toggleState, }) ) ); }; const ConfigSelection = ({ name, defaultValue, options, onChange = () => {} }) => { const [value, setValue] = useState(defaultValue); const setValueCallback = useCallback( (event) => { const value = event.target.value; setValue(value); onChange(value); }, [value] ); return react.createElement( "div", { className: "setting-row", }, react.createElement( "label", { className: "col description", }, name ), react.createElement( "div", { className: "col action", }, react.createElement( "select", { value, onChange: setValueCallback, }, Object.keys(options).map((item) => react.createElement( "option", { value: item, }, options[item] ) ) ) ) ); }; const ConfigInput = ({ name, defaultValue, onChange = () => {} }) => { const [value, setValue] = useState(defaultValue); const setValueCallback = useCallback( (event) => { const value = event.target.value; setValue(value); onChange(value); }, [value] ); return react.createElement( "div", { className: "setting-row", }, react.createElement( "label", { className: "col description", }, name ), react.createElement( "div", { className: "col action", }, react.createElement("input", { value, onChange: setValueCallback, }) ) ); }; const OptionList = ({ items, onChange }) => { const [_, setItems] = useState(items); return items.map((item) => { if (!item.when()) { return; } return react.createElement(item.type, { name: item.desc, defaultValue: item.defaultValue, options: item.options, onChange: (value) => { onChange(item.key, value); setItems([...items]); }, }); }); }; function openConfig() { const configContainer = react.createElement( "div", { id: `${APP_NAME}-config-container`, }, react.createElement(OptionList, { items: [ { desc: "Time range", key: "range", defaultValue: CONFIG.range, type: ConfigSelection, options: { 30: "30 days", 60: "60 days", 90: "90 days", 120: "120 days", }, when: () => true, }, { desc: "Date locale", key: "locale", defaultValue: CONFIG.locale, type: ConfigInput, when: () => true, }, { desc: "Relative date", key: "relative", defaultValue: CONFIG.relative, type: ConfigSlider, when: () => true, }, { desc: "Show type", key: "visual:type", defaultValue: CONFIG.visual.type, type: ConfigSlider, when: () => true, }, { desc: "Show track count", key: "visual:count", defaultValue: CONFIG.visual.count, type: ConfigSlider, when: () => true, }, { desc: "Fetch new podcast", key: "podcast", defaultValue: CONFIG.podcast, type: ConfigSlider, when: () => true, }, { desc: "Fetch new music", key: "music", defaultValue: CONFIG.music, type: ConfigSlider, when: () => true, }, { desc: Spicetify.Locale.get("artist.albums"), key: "album", defaultValue: CONFIG.album, type: ConfigSlider, when: () => CONFIG.music, }, { desc: Spicetify.Locale.get("artist.singles"), key: "single-ep", defaultValue: CONFIG["single-ep"], type: ConfigSlider, when: () => CONFIG.music, }, /* { desc: Spicetify.Locale.get("artist.appears-on"), key: "appears-on", defaultValue: CONFIG["appears-on"], type: ConfigSlider, when: () => CONFIG["music"] }, */ { desc: Spicetify.Locale.get("artist.compilations"), key: "compilations", defaultValue: CONFIG.compilations, type: ConfigSlider, when: () => CONFIG.music, }, ], onChange: (name, value) => { const subs = name.split(":"); if (subs.length > 1) { CONFIG[subs[0]][subs[1]] = value; gridUpdatePostsVisual(); } else { CONFIG[name] = value; } localStorage.setItem(`${APP_NAME}:${name}`, value); }, }), react.createElement( "div", { className: "setting-row", }, react.createElement( "label", { className: "col description", }, "Dismissed releases" ), react.createElement( "div", { className: "col action", }, react.createElement(ButtonText, { text: Spicetify.Locale.get("equalizer.reset"), onClick: removeCards.bind(this, null, "reset"), }) ) ) ); Spicetify.PopupModal.display({ title: Spicetify.Locale.get("new_releases"), content: configContainer, }); } ================================================ FILE: CustomApps/new-releases/index.js ================================================ // Run "npm i @types/react" to have this type package available in workspace /// /** @type {React} */ const { URI, React: react, React: { useState, useEffect, useCallback }, ReactDOM: reactDOM, Platform: { History }, CosmosAsync, } = Spicetify; // Define a function called "render" to specify app entry point // This function will be used to mount app to main view. function render() { return react.createElement(Grid); } function getConfig(name, defaultVal = true) { const value = localStorage.getItem(name); return value ? value === "true" : defaultVal; } const APP_NAME = "new-releases"; const CONFIG = { visual: { type: getConfig("new-releases:visual:type", true), count: getConfig("new-releases:visual:count", true), }, podcast: getConfig("new-releases:podcast", false), music: getConfig("new-releases:music", true), album: getConfig("new-releases:album", true), "single-ep": getConfig("new-releases:single-ep", true), // ["appears-on"]: getConfig("new-releases:appears-on", false), compilations: getConfig("new-releases:compilations", false), range: localStorage.getItem("new-releases:range") || "30", locale: localStorage.getItem("new-releases:locale") || navigator.language, relative: getConfig("new-releases:relative", false), }; let dismissed; try { dismissed = JSON.parse(Spicetify.LocalStorage.get("new-releases:dismissed")); if (!Array.isArray(dismissed)) throw ""; } catch { dismissed = []; } let gridList = []; let lastScroll = 0; let gridUpdatePostsVisual; let removeCards; let today = Date.now(); CONFIG.range = Number.parseInt(CONFIG.range) || 30; const DAY_DIVIDER = 24 * 3600 * 1000; let limitInMs = CONFIG.range * DAY_DIVIDER; const dateFormat = { year: "numeric", month: "short", day: "2-digit", }; const relativeDateFormat = { numeric: "auto", }; let separatedByDate = {}; let dateList = []; class Grid extends react.Component { viewportSelector = document.querySelector("#main .os-viewport") ? "#main .os-viewport" : "#main .main-view-container__scroll-node"; constructor() { super(); this.state = { cards: [], rest: true, }; } updatePostsVisual() { gridList = []; for (const date of dateList) { if (separatedByDate[date].every((card) => dismissed.includes(card.props.uri))) continue; gridList.push( react.createElement( "div", { className: "new-releases-header", }, react.createElement("h2", null, date) ), react.createElement( "div", { className: "main-gridContainer-gridContainer ", style: { "--min-container-width": "180px", "--column-count": "auto-fill", "--grid-gap": "18px", }, }, separatedByDate[date] .filter((card) => !dismissed.includes(card.props.uri)) .map((card) => react.createElement(Card, { ...card.props, key: card.props.uri })) ) ); } this.setState({ cards: [...gridList] }); } removeCards(id, type) { switch (type) { case "reset": Spicetify.showNotification("Reset dismissed releases"); dismissed = []; break; case "undo": if (!dismissed[0]) Spicetify.showNotification("Nothing to undo", true); else Spicetify.showNotification("Undone dismissal"); dismissed = id ? dismissed.filter((item) => item !== id) : dismissed.slice(0, -1); break; default: dismissed.push(id); break; } Spicetify.LocalStorage.set("new-releases:dismissed", JSON.stringify(dismissed)); this.updatePostsVisual(); } async reload() { gridList = []; separatedByDate = {}; dateList = []; today = Date.now(); CONFIG.range = Number.parseInt(CONFIG.range) || 30; limitInMs = CONFIG.range * DAY_DIVIDER; this.setState({ rest: false }); let items = []; if (CONFIG.music) { const tracks = await fetchTracks(); items.push(...tracks.flat()); } if (CONFIG.podcast) { const episodes = await fetchPodcasts(); items.push(...episodes); } items = items.filter(Boolean).sort((a, b) => b.time - a.time); let timeFormat; if (CONFIG.relative) { timeFormat = new Intl.RelativeTimeFormat(CONFIG.locale, relativeDateFormat); } else { timeFormat = new Intl.DateTimeFormat(CONFIG.locale, dateFormat); } for (const track of items) { track.visual = CONFIG.visual; let dateStr; if (CONFIG.relative) { const days = Math.ceil((track.time - today) / DAY_DIVIDER); dateStr = timeFormat.format(days, "day"); } else { dateStr = timeFormat.format(track.time); } if (!separatedByDate[dateStr]) { dateList.push(dateStr); separatedByDate[dateStr] = []; } separatedByDate[dateStr].push(react.createElement(Card, { ...track, key: track.uri })); } for (const date of dateList) { if (separatedByDate[date].every((card) => dismissed.includes(card.props.uri))) continue; gridList.push( react.createElement( "div", { className: "new-releases-header", }, react.createElement("h2", null, date) ), react.createElement( "div", { className: "main-gridContainer-gridContainer", style: { "--min-container-width": "180px", "--column-count": "auto-fill", "--grid-gap": "18px", }, }, separatedByDate[date].filter((card) => !dismissed.includes(card.props.uri)) ) ); } this.setState({ rest: true }); } async componentDidMount() { gridUpdatePostsVisual = this.updatePostsVisual.bind(this); removeCards = this.removeCards.bind(this); this.configButton = new Spicetify.Menu.Item( "New Releases config", false, openConfig, '' ); this.configButton.register(); const viewPort = document.querySelector(this.viewportSelector); if (gridList.length) { // Already loaded if (lastScroll > 0) { viewPort.scrollTo(0, lastScroll); } return; } this.reload(); } componentWillUnmount() { const viewPort = document.querySelector(this.viewportSelector); lastScroll = viewPort.scrollTop; this.configButton.deregister(); } render() { const expFeatures = JSON.parse(localStorage.getItem("spicetify-exp-features") || "{}"); const isGlobalNav = expFeatures?.enableGlobalNavBar?.value !== "control"; const version = Spicetify.Platform.version.split(".").map((i) => Number.parseInt(i)); const tabBarMargin = { marginTop: isGlobalNav || (version[0] === 1 && version[1] === 2 && version[2] >= 45) ? "60px" : "0px", }; return react.createElement( "section", { className: "contentSpacing", }, react.createElement( "div", { className: "new-releases-header", style: tabBarMargin, }, react.createElement("h1", null, Spicetify.Locale.get("new_releases")), react.createElement( "div", { className: "new-releases-controls-container", }, react.createElement(ButtonText, { text: Spicetify.Locale.get("playlist.extender.refresh"), onClick: this.reload.bind(this), }), react.createElement(ButtonText, { text: "undo", // no locale for this onClick: this.removeCards.bind(this, null, "undo"), }) ) ), this.state.rest ? gridList : LoadingIcon ); } } async function getArtistList() { const config = { filters: ["1"], sortOrder: ["0"], textFilter: "", offset: 0, limit: 50000, }; const artists = await Spicetify.Platform.LibraryAPI.getContents(config); count(true); return artists.items ?? []; } async function getArtistEverything(artist) { const { data, errors } = await Spicetify.GraphQL.Request( { name: "queryArtistDiscographyAll", operation: "query", sha256Hash: "9380995a9d4663cbcb5113fef3c6aabf70ae6d407ba61793fd01e2a1dd6929b0", value: null, }, { uri: artist.uri, offset: 0, // Limit 100 since GraphQL has resource limit limit: 100, } ); if (errors) throw errors; const releases = data?.artistUnion.discography.all.items.flatMap((r) => r.releases.items); const items = []; const types = [ [CONFIG.album, releases.filter((r) => r.type === "ALBUM"), Spicetify.Locale.get("album")], // Appears on has a separate GraphQL query but does not provide enough information (release date), which requires recursively making requests for each album // [CONFIG["appears-on"], releases.appears_on?.releases, Spicetify.Locale.get("artist.appears-on")], [CONFIG.compilations, releases.filter((r) => r.type === "COMPILATION"), Spicetify.Locale.get("compilation")], [ CONFIG["single-ep"], releases.filter((r) => r.type === "SINGLE" || r.type === "EP"), `${Spicetify.Locale.get("single")}/${Spicetify.Locale.get("ep")}`, ], ]; for (const type of types) { if (type[0] && type[1]) { for (const item of type[1]) { const meta = metaFromTrack(artist, item); if (!meta) continue; meta.type = type[2]; items.push(meta); } } } return items; } async function getPodcastList() { const body = await Spicetify.Platform.LibraryAPI.getShows({ limit: 50000 }); return body.items ?? []; } async function getPodcastRelease(uri) { const body = await Spicetify.Platform.ShowAPI.getContents(uri, { limit: 50000 }); return body.items; } function metaFromTrack(artist, track) { const time = Date.parse(track.date.isoString); if (today - time < limitInMs) { return { uri: track.uri, title: track.name, artist: { name: artist.name, uri: artist.uri, }, imageURL: track.coverArt.sources.reduce((prev, curr) => (prev.width > curr.width ? prev : curr)).url, time, trackCount: track.tracks.totalCount, }; } return null; } const count = (() => { let counter = 0; return (reset = false) => { if (reset) counter = 0; else counter++; }; })(); async function fetchTracks() { const artistList = await getArtistList(); Spicetify.showNotification(`Fetching releases from ${artistList.length} artists`); const requests = artistList.map(async (obj) => { return await getArtistEverything(obj).catch((err) => { console.debug("Could not fetch all releases", err); console.debug(`Missing releases from ${count()} artists`); }); }); return await Promise.all(requests); } async function fetchPodcasts() { const items = []; const itemTypeStr = Spicetify.Locale.get("card.tag.episode"); for (const podcast of await getPodcastList()) { const tracks = await getPodcastRelease(podcast.uri); if (!tracks) continue; for (const track of tracks) { const time = new Date(track.releaseDate.isoString); if (today - time.getTime() > limitInMs) { break; } items.push({ uri: track.uri, title: track.name, artist: { name: podcast.name, uri: podcast.uri, }, imageURL: track.coverArt.reduce((prev, curr) => (prev.width > curr.width ? prev : curr)).url, time, type: itemTypeStr, }); } } return items; } ================================================ FILE: CustomApps/new-releases/manifest.json ================================================ { "name": { "ms": "Keluaran Baharu", "gu": "નવા રિલીઝ", "ko": "최신 음악", "pa-IN": "ਨਵੇਂ ਰਿਲੀਜ਼", "az": "Yeni buraxılışlar", "ru": "Новые релизы", "uk": "Нові релізи", "nb": "Nye utgivelser", "sv": "Nya releaser", "sw": "Matoleo Mapya", "ur": "نئی ریلیزز", "bho": "नवका रिलीज़", "pa-PK": "نویں ریلیزاں", "te": "క్రొత్త రిలీజ్‌లు", "ro": "Lansări noi", "vi": "Mới Phát Hành", "am": "አዳዲስ የተለቀቁ", "bn": "নতুন রিলিজ", "en": "New Releases", "id": "Rilis Terbaru", "bg": "Нови издания", "da": "Nye udgivelser", "es-419": "Nuevos Lanzamientos", "mr": "नवीन रिलीझ", "ml": "പുതിയ റിലീസുകള്‍", "th": "ออกใหม่ล่าสุด", "tr": "Yeni Çıkanlar", "is": "Nýjar útgáfur", "fa": "تولیدات جدید", "or": "ନୂଆ ରିଲିଜଗୁଡ଼ିକ", "he": "מה חדש?", "hi": "नई रिलीज़", "zh-TW": "最新發行", "sr": "Nova izdanja", "pt-BR": "Novos lançamentos", "zu": "Ezisanda Kukhishwa", "nl": "Nieuwe releases", "es": "Novedades", "lt": "Nauji leidimai", "ja": "ニューリリース", "st": "Nova izdanja", "it": "Nuove uscite", "el": "Νέες κυκλοφορίες", "pt-PT": "Novos lançamentos", "kn": "ಹೊಸ ಬಿಡುಗಡೆಗಳು", "de": "Neuerscheinungen", "fr": "Nouveautés", "ne": "नयाँ रिलिजहरू", "ar": "الإصدارات الجديدة", "af": "Nuwe vrystellings", "et": "Uus muusika", "pl": "Nowe wydania", "ta": "புதிய வெளியீடுகள்", "sl": "Nove izdaje", "pk": "New Releases", "hr": "Nova izdanja", "sk": "Novinky", "fi": "Uudet julkaisut", "lv": "Jaunumi", "fil": "Mga Bagong Release", "fr-CA": "Nouveautés", "cs": "Čerstvé novinky", "zh-CN": "新歌热播", "hu": "Újdonságok" }, "icon": "", "active-icon": "", "subfiles": ["Card.js", "Icons.js", "Settings.js"] } ================================================ FILE: CustomApps/new-releases/style.css ================================================ .setting-row::after { content: ""; display: table; clear: both; } .setting-row .col { display: flex; padding: 10px 0; align-items: center; } .setting-row .col.description { float: left; padding-right: 15px; cursor: default; } .setting-row .col.action { float: right; text-align: right; } button.switch { align-items: center; border: 0px; border-radius: 50%; background-color: rgba(var(--spice-rgb-shadow), 0.7); color: var(--spice-text); cursor: pointer; display: flex; margin-inline-start: 12px; padding: 8px; } button.switch.disabled, button.switch[disabled] { color: rgba(var(--spice-rgb-text), 0.3); } button.switch.small { width: 22px; height: 22px; padding: 6px; } button.text { font-size: 12px; line-height: 16px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; text-align: center; color: var(--spice-text); background-color: initial; padding: 7px 15px; border: 1px solid var(--spice-text); -webkit-box-sizing: border-box; box-sizing: border-box; border-radius: 4px; margin-inline-start: 12px; } #new-releases-config-container input { width: 100%; margin-top: 10px; padding: 0 5px; height: 32px; border: 0; color: var(--spice-text); background-color: initial; border-bottom: 1px solid var(--spice-text); } option { background-color: var(--spice-button); } .new-releases-header { -webkit-box-pack: justify; -webkit-box-align: center; align-content: space-between; align-items: center; color: var(--spice-text); display: flex; justify-content: space-between; margin: 16px 0; text-transform: capitalize; } .new-releases-controls-container { position: relative; align-items: center; display: flex; } .new-releases-cardSubHeader { color: var(--spice-subtext); } .new-releases-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton button { -webkit-tap-highlight-color: transparent; background-color: transparent; border: 0px; border-radius: 500px; display: inline-block; position: relative; touch-action: manipulation; transition-duration: 33ms; transition-property: background-color, border-color, color, box-shadow, filter, transform; user-select: none; vertical-align: middle; transform: translate3d(0px, 0px, 0px); padding: 0px; min-inline-size: 0px; align-self: center; } .new-releases-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton span { -webkit-tap-highlight-color: transparent; position: relative; background-color: var(--spice-button-active); color: var(--spice-sidebar); display: flex; border-radius: 500px; font-size: inherit; min-block-size: 48px; -webkit-box-align: center; align-items: center; -webkit-box-pack: center; justify-content: center; inline-size: 48px; block-size: 48px; } .new-releases-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton span:hover { transform: scale(1.04); } .main-card-closeButton { pointer-events: all; position: absolute !important; top: 8px; right: 8px; -webkit-box-align: center; -ms-flex-align: center; -webkit-box-pack: center; -ms-flex-pack: center; align-items: center; background-color: rgba(var(--spice-rgb-shadow), 0.7); border: 0; border-radius: 500px; color: var(--spice-sidebar); display: flex; height: 28px; justify-content: center; -webkit-transform: scale(1); transform: scale(1); -webkit-transform-origin: center; transform-origin: center; width: 28px; visibility: hidden; opacity: 0; transition: visibility 0s, opacity 0.3s ease; } .main-card-closeButton:active { transform: scale(1) !important; } .main-card-closeButton:hover { transform: scale(1.1); } .main-card-card:hover .main-card-closeButton { visibility: visible; opacity: 1; transition: visibility 0s, opacity 0.3s ease; } .new-releases-header + .main-gridContainer-gridContainer { grid-template-columns: repeat(var(--column-count), minmax(var(--min-container-width), 1fr)) !important; } ================================================ FILE: CustomApps/reddit/Card.js ================================================ class Card extends react.Component { constructor(props) { super(props); Object.assign(this, props); const uriObj = URI.fromString(this.uri); this.href = uriObj.toURLPath(true); this.uriType = uriObj.type; switch (this.uriType) { case URI.Type.ALBUM: case URI.Type.TRACK: this.menuType = Spicetify.ReactComponent.AlbumMenu; break; case URI.Type.ARTIST: this.menuType = Spicetify.ReactComponent.ArtistMenu; break; case URI.Type.PLAYLIST: case URI.Type.PLAYLIST_V2: this.menuType = Spicetify.ReactComponent.PlaylistMenu; break; case URI.Type.SHOW: this.menuType = Spicetify.ReactComponent.PodcastShowMenu; break; } this.menuType = this.menuType || "div"; } play(event) { Spicetify.Player.playUri(this.uri, this.context); event.stopPropagation(); } getSubtitle() { let subtitle; if ((this.uriType === URI.Type.ALBUM || this.uriType === URI.Type.TRACK) && Array.isArray(this.subtitle)) { subtitle = this.subtitle.map((artist) => { const artistHref = URI.fromString(artist.uri).toURLPath(true); return react.createElement( "a", { href: artistHref, onClick: (event) => { event.preventDefault(); event.stopPropagation(); History.push(artistHref); }, }, react.createElement("span", null, artist.name) ); }); // Insert commas between elements subtitle = subtitle.flatMap((el, i, arr) => (arr.length - 1 !== i ? [el, ", "] : el)); } else { subtitle = react.createElement( "div", { className: `${this.visual.longDescription ? "reddit-longDescription " : ""}main-cardSubHeader-root main-type-mesto reddit-cardSubHeader`, as: "div", }, react.createElement("span", null, this.subtitle) ); } return react.createElement( "div", { className: "reddit-cardSubHeader main-type-mesto", }, subtitle ); } getFollowers() { if (this.visual.followers && (this.uriType === URI.Type.PLAYLIST || this.uriType === URI.Type.PLAYLIST_V2)) { return react.createElement( "div", { className: "main-cardSubHeader-root main-type-mestoBold reddit-cardSubHeader", as: "div", }, react.createElement("span", null, Spicetify.Locale.get("user.followers", this.followersCount)) ); } } render() { const detail = []; this.visual.type && detail.push(this.type); this.visual.upvotes && detail.push(`▲ ${this.upvotes}`); return react.createElement( Spicetify.ReactComponent.RightClickMenu || "div", { menu: react.createElement(this.menuType, { uri: this.uri }), }, react.createElement( "div", { className: "main-card-card", onClick: (event) => { History.push(this.href); event.preventDefault(); }, }, react.createElement( "div", { className: "main-card-draggable", draggable: "true", }, react.createElement( "div", { className: "main-card-imageContainer", }, react.createElement( "div", { className: "main-cardImage-imageWrapper", }, react.createElement( "div", {}, react.createElement("img", { "aria-hidden": "false", draggable: "false", loading: "lazy", src: this.imageURL, className: "main-image-image main-cardImage-image", }) ) ), react.createElement( "div", { className: "main-card-PlayButtonContainer", }, react.createElement( "div", { className: "main-playButton-PlayButton main-playButton-primary", "aria-label": Spicetify.Locale.get("play"), style: { "--size": "40px" }, onClick: this.play.bind(this), }, react.createElement( "button", null, react.createElement( "span", null, react.createElement( "svg", { height: "24", role: "img", width: "24", viewBox: "0 0 24 24", "aria-hidden": "true", }, react.createElement("polygon", { points: "21.57 12 5.98 3 5.98 21 21.57 12", fill: "currentColor", }) ) ) ) ) ) ), react.createElement( "div", { className: "main-card-cardMetadata", }, react.createElement( "a", { draggable: "false", title: this.title, className: "main-cardHeader-link", dir: "auto", href: this.href, }, react.createElement( "div", { className: "main-cardHeader-text main-type-balladBold", as: "div", }, this.title ) ), detail.length > 0 && react.createElement( "div", { className: "main-cardSubHeader-root main-type-mestoBold reddit-cardSubHeader", as: "div", }, react.createElement("span", null, detail.join(" ‒ ")) ), this.getFollowers(), this.getSubtitle() ) ) ) ); } } ================================================ FILE: CustomApps/reddit/Icons.js ================================================ class LoadingIcon extends react.Component { render() { return react.createElement( "svg", { width: "100px", height: "100px", viewBox: "0 0 100 100", preserveAspectRatio: "xMidYMid", }, react.createElement( "circle", { cx: "50", cy: "50", r: "0", fill: "none", stroke: "currentColor", "stroke-width": "2", }, react.createElement("animate", { attributeName: "r", repeatCount: "indefinite", dur: "1s", values: "0;40", keyTimes: "0;1", keySplines: "0 0.2 0.8 1", calcMode: "spline", begin: "0s", }), react.createElement("animate", { attributeName: "opacity", repeatCount: "indefinite", dur: "1s", values: "1;0", keyTimes: "0;1", keySplines: "0.2 0 0.8 1", calcMode: "spline", begin: "0s", }) ), react.createElement( "circle", { cx: "50", cy: "50", r: "0", fill: "none", stroke: "currentColor", "stroke-width": "2", }, react.createElement("animate", { attributeName: "r", repeatCount: "indefinite", dur: "1s", values: "0;40", keyTimes: "0;1", keySplines: "0 0.2 0.8 1", calcMode: "spline", begin: "-0.5s", }), react.createElement("animate", { attributeName: "opacity", repeatCount: "indefinite", dur: "1s", values: "1;0", keyTimes: "0;1", keySplines: "0.2 0 0.8 1", calcMode: "spline", begin: "-0.5s", }) ) ); } } class LoadMoreIcon extends react.Component { render() { return react.createElement( "div", { onClick: this.props.onClick, }, react.createElement( "p", { style: { fontSize: 100, lineHeight: "65px", }, }, "»" ), react.createElement( "span", { style: { fontSize: 20, }, }, "Load more" ) ); } } ================================================ FILE: CustomApps/reddit/OptionsMenu.js ================================================ const OptionsMenuItemIcon = react.createElement( "svg", { width: 16, height: 16, viewBox: "0 0 16 16", fill: "currentColor", }, react.createElement("path", { d: "M13.985 2.383L5.127 12.754 1.388 8.375l-.658.77 4.397 5.149 9.618-11.262z", }) ); const OptionsMenuItem = react.memo(({ onSelect, value, isSelected }) => { return react.createElement( Spicetify.ReactComponent.MenuItem, { onClick: onSelect, icon: isSelected ? OptionsMenuItemIcon : null, trailingIcon: isSelected ? OptionsMenuItemIcon : null, }, value ); }); const OptionsMenu = react.memo(({ options, onSelect, selected, defaultValue, bold = false }) => { /** * ) } * > * * */ const menuRef = react.useRef(null); return react.createElement( Spicetify.ReactComponent.ContextMenu, { menu: react.createElement( Spicetify.ReactComponent.Menu, {}, options.map(({ key, value }) => react.createElement(OptionsMenuItem, { value, onSelect: () => { onSelect(key); // Close menu on item click menuRef.current?.click(); }, isSelected: selected?.key === key, }) ) ), trigger: "click", action: "toggle", renderInline: false, }, react.createElement( "button", { className: "optionsMenu-dropBox", ref: menuRef, }, react.createElement( "span", { className: bold ? "main-type-mestoBold" : "main-type-mesto", }, selected?.value || defaultValue ), react.createElement( "svg", { height: "16", width: "16", fill: "currentColor", viewBox: "0 0 16 16", }, react.createElement("path", { d: "M3 6l5 5.794L13 6z", }) ) ) ); }); ================================================ FILE: CustomApps/reddit/Settings.js ================================================ let configContainer; function openConfig() { if (configContainer) { Spicetify.PopupModal.display({ title: "Reddit", content: configContainer, }); return; } CONFIG.servicesElement = {}; configContainer = document.createElement("div"); configContainer.id = "reddit-config-container"; const optionHeader = document.createElement("h2"); optionHeader.innerText = "Options"; const serviceHeader = document.createElement("h2"); serviceHeader.innerText = "Subreddits"; const serviceContainer = document.createElement("div"); function stackServiceElements() { CONFIG.services.forEach((name, index) => { const el = CONFIG.servicesElement[name]; const [up, down] = el.querySelectorAll("button"); if (CONFIG.services.length === 1) { up.disabled = true; down.disabled = true; } else if (index === 0) { up.disabled = true; down.disabled = false; } else if (index === CONFIG.services.length - 1) { up.disabled = false; down.disabled = true; } else { up.disabled = false; down.disabled = false; } serviceContainer.append(el); }); gridUpdateTabs?.(); } function posCallback(el, dir) { const id = el.dataset.id; const curPos = CONFIG.services.findIndex((val) => val === id); const newPos = curPos + dir; if (CONFIG.services.length > 1) { const temp = CONFIG.services[newPos]; CONFIG.services[newPos] = CONFIG.services[curPos]; CONFIG.services[curPos] = temp; } localStorage.setItem("reddit:services", JSON.stringify(CONFIG.services)); stackServiceElements(); } function removeCallback(el) { const id = el.dataset.id; CONFIG.services = CONFIG.services.filter((s) => s !== id); CONFIG.servicesElement[id].remove(); localStorage.setItem("reddit:services", JSON.stringify(CONFIG.services)); stackServiceElements(); } for (const name of CONFIG.services) { CONFIG.servicesElement[name] = createServiceOption(name, posCallback, removeCallback); } stackServiceElements(); const serviceInput = document.createElement("input"); serviceInput.placeholder = "Add new subreddit"; serviceInput.onkeydown = (event) => { if (event.key !== "Enter") { return; } event.preventDefault(); const name = serviceInput.value; if (!CONFIG.services.includes(name)) { CONFIG.services.push(name); CONFIG.servicesElement[name] = createServiceOption(name, posCallback, removeCallback); localStorage.setItem("reddit:services", JSON.stringify(CONFIG.services)); } stackServiceElements(); serviceInput.value = ""; const parent = configContainer.parentElement.parentElement; parent.scrollTo(0, parent.scrollHeight); }; configContainer.append( optionHeader, createSlider("Upvotes count", "upvotes"), createSlider("Followers count", "followers"), createSlider("Post type", "type"), createSlider("Long description", "longDescription"), serviceHeader, serviceContainer, serviceInput ); Spicetify.PopupModal.display({ title: "Reddit", content: configContainer, }); } function createSlider(name, key) { const container = document.createElement("div"); container.innerHTML = `
`; const slider = container.querySelector("button"); slider.classList.toggle("disabled", !CONFIG.visual[key]); slider.onclick = () => { const state = !slider.classList.toggle("disabled"); CONFIG.visual[key] = state; localStorage.setItem(`reddit:${key}`, String(state)); gridUpdatePostsVisual?.(); }; return container; } function createServiceOption(id, posCallback, removeCallback) { const container = document.createElement("div"); container.dataset.id = id; container.innerHTML = `

${id}

`; const [up, down, remove] = container.querySelectorAll("button"); up.onclick = () => posCallback(container, -1); down.onclick = () => posCallback(container, 1); remove.onclick = () => removeCallback(container); return container; } ================================================ FILE: CustomApps/reddit/SortBox.js ================================================ class SortBox extends react.Component { constructor(props) { super(props); this.sortByOptions = [ { key: "hot", value: "Hot" }, { key: "new", value: "New" }, { key: "top", value: "Top" }, { key: "rising", value: "Rising" }, { key: "controversial", value: "Controversial" }, ]; this.sortTimeOptions = [ { key: "hour", value: "Hour" }, { key: "day", value: "Day" }, { key: "week", value: "Week" }, { key: "month", value: "Month" }, { key: "year", value: "Year" }, { key: "all", value: "All" }, ]; } render() { const sortBySelected = this.sortByOptions.filter((a) => a.key === sortConfig.by)[0]; const sortTimeSelected = this.sortTimeOptions.filter((a) => a.key === sortConfig.time)[0]; return react.createElement( "div", { className: "reddit-sort-bar", }, react.createElement( "div", { className: "reddit-sort-container", }, react.createElement(OptionsMenu, { options: this.sortByOptions, onSelect: (by) => this.props.onChange(by, null), selected: sortBySelected, }), !!sortConfig.by.match(/top|controversial/) && react.createElement(OptionsMenu, { options: this.sortTimeOptions, onSelect: (time) => this.props.onChange(null, time), selected: sortTimeSelected, }) ) ); } } ================================================ FILE: CustomApps/reddit/TabBar.js ================================================ class TabBarItem extends react.Component { render() { return react.createElement( "li", { className: "reddit-tabBar-headerItem", onClick: (event) => { event.preventDefault(); this.props.switchTo(this.props.item.key); }, }, react.createElement( "a", { "aria-current": "page", className: `reddit-tabBar-headerItemLink ${this.props.item.active ? "reddit-tabBar-active" : ""}`, draggable: "false", href: "", }, react.createElement( "span", { className: "main-type-mestoBold", }, this.props.item.value ) ) ); } } const TabBarMore = react.memo(({ items, switchTo }) => { const activeItem = items.find((item) => item.active); return react.createElement( "li", { className: `reddit-tabBar-headerItem ${activeItem ? "reddit-tabBar-active" : ""}`, }, react.createElement(OptionsMenu, { options: items, onSelect: switchTo, selected: activeItem, defaultValue: "More", bold: true, }) ); }); const TopBarContent = ({ links, activeLink, switchCallback }) => { const resizeHost = document.querySelector( ".Root__main-view .os-resize-observer-host, .Root__main-view .os-size-observer, .Root__main-view .main-view-container__scroll-node" ); const [windowSize, setWindowSize] = useState(resizeHost.clientWidth); const resizeHandler = () => setWindowSize(resizeHost.clientWidth); useEffect(() => { const observer = new ResizeObserver(resizeHandler); observer.observe(resizeHost); return () => { observer.disconnect(); }; }, [resizeHandler]); return react.createElement( TabBarContext, null, react.createElement(TabBar, { className: "queue-queueHistoryTopBar-tabBar", links, activeLink, windowSize, switchCallback, }) ); }; const TabBarContext = ({ children }) => { return Spicetify.ReactDOM.createPortal( react.createElement( "div", { className: "main-topBar-topbarContent", }, children ), document.querySelector(".main-topBar-topbarContentWrapper") ); }; const TabBar = react.memo(({ links, activeLink, switchCallback, windowSize = Number.POSITIVE_INFINITY }) => { const tabBarRef = react.useRef(null); const [childrenSizes, setChildrenSizes] = useState([]); const [availableSpace, setAvailableSpace] = useState(0); const [droplistItem, setDroplistItems] = useState([]); const options = links.map((key) => { const active = key === activeLink; return { key, value: key, active }; }); useEffect(() => { if (!tabBarRef.current) return; setAvailableSpace(tabBarRef.current.clientWidth); }, [windowSize]); useEffect(() => { if (!tabBarRef.current) return; const children = Array.from(tabBarRef.current.children); const tabbarItemSizes = children.map((child) => child.clientWidth); setChildrenSizes(tabbarItemSizes); }, [links]); useEffect(() => { if (!tabBarRef.current) return; const totalSize = childrenSizes.reduce((a, b) => a + b, 0); // Can we render everything? if (totalSize <= availableSpace) { setDroplistItems([]); return; } // The `More` button can be set to _any_ of the children. So we // reserve space for the largest item instead of always taking // the last item. const viewMoreButtonSize = Math.max(...childrenSizes); // Figure out how many children we can render while also showing // the More button const itemsToHide = []; let stopWidth = viewMoreButtonSize; childrenSizes.forEach((childWidth, i) => { if (availableSpace >= stopWidth + childWidth) { stopWidth += childWidth; } else { itemsToHide.push(i); } }); setDroplistItems(itemsToHide); }, [availableSpace, childrenSizes]); return react.createElement( "nav", { className: "reddit-tabBar reddit-tabBar-nav", }, react.createElement( "ul", { className: "reddit-tabBar-header", ref: tabBarRef, }, options .filter((_, id) => !droplistItem.includes(id)) .map((item) => react.createElement(TabBarItem, { item, switchTo: switchCallback, }) ), droplistItem.length || childrenSizes.length === 0 ? react.createElement(TabBarMore, { items: droplistItem.map((i) => options[i]).filter(Boolean), switchTo: switchCallback, }) : null ) ); }); ================================================ FILE: CustomApps/reddit/index.js ================================================ // Run "npm i @types/react-dom @types/react" to have this type package available in workspace /// /// /** @type {React} */ const react = Spicetify.React; /** @type {ReactDOM} */ const reactDOM = Spicetify.ReactDOM; const { URI, React: { useState, useEffect, useCallback }, Platform: { History }, } = Spicetify; // Define a function called "render" to specify app entry point // This function will be used to mount app to main view. function render() { return react.createElement(Grid, { title: "Reddit" }); } const CONFIG = { visual: { type: localStorage.getItem("reddit:type") === "true", upvotes: localStorage.getItem("reddit:upvotes") === "true", followers: localStorage.getItem("reddit:followers") === "true", longDescription: localStorage.getItem("reddit:longDescription") === "true", }, services: localStorage.getItem("reddit:services") || `["spotify","makemeaplaylist","SpotifyPlaylists","music","edm","popheads"]`, lastService: localStorage.getItem("reddit:last-service"), }; try { CONFIG.services = JSON.parse(CONFIG.services); if (!Array.isArray(CONFIG.services)) { throw ""; } } catch { CONFIG.services = ["spotify", "makemeaplaylist", "SpotifyPlaylists", "music", "edm", "popheads"]; localStorage.setItem("reddit:services", JSON.stringify(CONFIG.services)); } if (!CONFIG.lastService || !CONFIG.services.includes(CONFIG.lastService)) { CONFIG.lastService = CONFIG.services[0]; } const sortConfig = { by: localStorage.getItem("reddit:sort-by") || "top", time: localStorage.getItem("reddit:sort-time") || "month", }; let cardList = []; let endOfList = false; let lastScroll = 0; let requestQueue = []; let requestAfter = null; let gridUpdateTabs; let gridUpdatePostsVisual; const typesLocale = { album: Spicetify.Locale.get("album"), song: Spicetify.Locale.get("song"), playlist: Spicetify.Locale.get("playlist"), }; class Grid extends react.Component { viewportSelector = document.querySelector("#main .os-viewport") ? "#main .os-viewport" : "#main .main-view-container__scroll-node"; constructor(props) { super(props); Object.assign(this, props); this.state = { cards: [], tabs: CONFIG.services, rest: true, endOfList: endOfList, }; } newRequest(amount) { cardList = []; const queue = []; requestQueue.unshift(queue); this.loadAmount(queue, amount); } appendCard(item) { item.visual = CONFIG.visual; cardList.push(react.createElement(Card, item)); this.setState({ cards: cardList }); } updateSort(sortByValue, sortTimeValue) { if (sortByValue) { sortConfig.by = sortByValue; localStorage.setItem("reddit:sort-by", sortByValue); } if (sortTimeValue) { sortConfig.time = sortTimeValue; localStorage.setItem("reddit:sort-time", sortTimeValue); } requestAfter = null; cardList = []; this.setState({ cards: [], rest: false, endOfList: false, }); endOfList = false; this.newRequest(30); } updateTabs() { this.setState({ tabs: [...CONFIG.services], }); } updatePostsVisual() { cardList = cardList.map((card) => { return react.createElement(Card, card.props); }); this.setState({ cards: [...cardList] }); } switchTo(value) { CONFIG.lastService = value; localStorage.setItem("reddit:last-service", value); cardList = []; requestAfter = null; this.setState({ cards: [], rest: false, endOfList: false, }); endOfList = false; this.newRequest(30); } async loadPage(queue) { const subMeta = await getSubreddit(requestAfter); const posts = postMapper(subMeta.data.children); for (const post of posts) { let item; switch (post.type) { case "playlist": case "playlist-v2": item = await fetchPlaylist(post); break; case "track": item = await fetchTrack(post); break; case "album": item = await fetchAlbum(post); break; } if (requestQueue.length > 1 && queue !== requestQueue[0]) { // Stop this queue from continuing to fetch and append to cards list return -1; } item && this.appendCard(item); } if (subMeta.data.after) { return subMeta.data.after; } this.setState({ rest: true, endOfList: true }); endOfList = true; return null; } async loadAmount(queue, quantity = 50) { this.setState({ rest: false }); let addQuantity = quantity; addQuantity += cardList.length; requestAfter = await this.loadPage(queue); while (requestAfter && requestAfter !== -1 && cardList.length < addQuantity && !this.endOfList) { requestAfter = await this.loadPage(queue); } if (requestAfter === -1) { requestQueue = requestQueue.filter((a) => a !== queue); return; } // Remove this queue from queue list requestQueue.shift(); this.setState({ rest: true }); } loadMore() { if (this.state.rest && !endOfList) { this.loadAmount(requestQueue[0], 50); } } async componentDidMount() { gridUpdateTabs = this.updateTabs.bind(this); gridUpdatePostsVisual = this.updatePostsVisual.bind(this); this.configButton = new Spicetify.Menu.Item("Reddit config", false, openConfig); this.configButton.register(); const viewPort = document.querySelector(this.viewportSelector); this.checkScroll = this.isScrolledBottom.bind(this); viewPort.addEventListener("scroll", this.checkScroll); if (cardList.length) { // Already loaded if (lastScroll > 0) { viewPort.scrollTo(0, lastScroll); } return; } this.newRequest(30); } componentWillUnmount() { gridUpdateTabs = gridUpdatePostsVisual = null; const viewPort = document.querySelector(this.viewportSelector); lastScroll = viewPort.scrollTop; viewPort.removeEventListener("scroll", this.checkScroll); this.configButton.deregister(); } isScrolledBottom(event) { const viewPort = event.target; if (viewPort.scrollTop + viewPort.clientHeight >= viewPort.scrollHeight) { // At bottom, load more posts this.loadMore(); } } render() { const expFeatures = JSON.parse(localStorage.getItem("spicetify-exp-features") || "{}"); const isGlobalNav = expFeatures?.enableGlobalNavBar?.value !== "control"; const version = Spicetify.Platform.version.split(".").map((i) => Number.parseInt(i)); const tabBarMargin = { marginTop: isGlobalNav || (version[0] === 1 && version[1] === 2 && version[2] >= 45) ? "60px" : "0px", }; return react.createElement( "section", { className: "contentSpacing", }, react.createElement( "div", { className: "reddit-header", style: tabBarMargin, }, react.createElement("h1", null, this.props.title), react.createElement(SortBox, { onChange: this.updateSort.bind(this), onServicesChange: this.updateTabs.bind(this), }) ), react.createElement( "div", { id: "reddit-grid", className: "main-gridContainer-gridContainer main-gridContainer-fixedWidth", style: { "--minimumColumnWidth": "180px", "--column-width": "minmax(var(--minimumColumnWidth),1fr)", "--column-count": "auto-fill", "--grid-gap": "24px", }, }, [...cardList] ), react.createElement( "footer", { style: { margin: "auto", textAlign: "center", }, }, !this.state.endOfList && (this.state.rest ? react.createElement(LoadMoreIcon, { onClick: this.loadMore.bind(this) }) : react.createElement(LoadingIcon)) ), !!document.querySelector(".main-topBar-topbarContentWrapper") && react.createElement(TopBarContent, { switchCallback: this.switchTo.bind(this), links: CONFIG.services, activeLink: CONFIG.lastService, }) ); } } async function getSubreddit(after = "") { // www is needed or it will block with "cross-origin" error. let url = `https://www.reddit.com/r/${CONFIG.lastService}/${sortConfig.by}.json?limit=100&count=10&raw_json=1`; if (after) { url += `&after=${after}`; } if (sortConfig.by.match(/top|controversial/) && sortConfig.time) { url += `&t=${sortConfig.time}`; } return await fetch(url, { method: "GET" }).then((res) => res.json()); } async function fetchPlaylist(post) { try { const res = await Spicetify.CosmosAsync.get(`sp://core-playlist/v1/playlist/${post.uri}/metadata`, { policy: { name: true, picture: true, followers: true, }, }); const { metadata } = res; return { type: typesLocale.playlist, uri: post.uri, title: metadata.name, subtitle: post.title, imageURL: metadata.picture, upvotes: post.upvotes, followersCount: metadata.followers, }; } catch { return null; } } async function fetchAlbum(post) { const { getAlbum } = Spicetify.GraphQL.Definitions; try { const { data } = await Spicetify.GraphQL.Request(getAlbum, { uri: post.uri, locale: Spicetify.Locale.getLocale(), offset: 0, limit: 10, }); const metadata = data.albumUnion; return { type: typesLocale.album, uri: post.uri, title: metadata.name, subtitle: metadata.artists.items.map((artist) => artist.profile.name).join(", "), imageURL: metadata.coverArt.sources.reduce((prev, curr) => (prev.width > curr.width ? prev : curr)).url, upvotes: post.upvotes, }; } catch { return null; } } async function fetchTrack(post) { const arg = post.uri.split(":")[2]; try { const metadata = await Spicetify.CosmosAsync.get(`https://api.spotify.com/v1/tracks/${arg}`); return { type: typesLocale.song, uri: post.uri, title: metadata.name, subtitle: metadata.artists, imageURL: metadata.album.images[0].url, upvotes: post.upvotes, }; } catch { return null; } } function postMapper(posts) { const mappedPosts = []; for (const post of posts) { const uri = URI.from(post.data.url); if (uri && (uri.type === "playlist" || uri.type === "playlist-v2" || uri.type === "track" || uri.type === "album")) { mappedPosts.push({ uri: uri.toURI(), type: uri.type, title: post.data.title, upvotes: post.data.ups, }); } } return mappedPosts; } ================================================ FILE: CustomApps/reddit/manifest.json ================================================ { "name": "Reddit", "icon": "", "active-icon": "", "subfiles": ["Card.js", "Icons.js", "OptionsMenu.js", "SortBox.js", "TabBar.js", "Settings.js"] } ================================================ FILE: CustomApps/reddit/style.css ================================================ .setting-row::after { content: ""; display: table; clear: both; } .setting-row .col { display: flex; padding: 10px 0; align-items: center; } .setting-row .col.description { float: left; padding-right: 15px; cursor: default; } .setting-row .col.action { float: right; text-align: right; } button.switch { align-items: center; border: 0px; border-radius: 50%; background-color: rgba(var(--spice-rgb-shadow), 0.7); color: var(--spice-text); cursor: pointer; display: flex; margin-inline-start: 12px; padding: 8px; } button.switch.disabled, button.switch[disabled] { color: rgba(var(--spice-rgb-text), 0.3); } button.switch.small { width: 22px; height: 22px; padding: 6px; } .reddit-sort-container .optionsMenu-dropBox { grid-gap: 8px; align-items: center; background-color: transparent; border-radius: 4px; display: grid; grid-template-columns: 1fr 16px; color: rgba(var(--spice-rgb-text), 0.7); border: 0; height: 32px; margin-left: 8px; padding: 0 8px 0 12px; } .reddit-sort-container .optionsMenu-dropBox:hover { color: var(--spice-text); } #reddit-config-container input { width: 100%; margin-top: 10px; padding: 0 5px; height: 32px; border: 0; color: var(--spice-text); background-color: initial; border-bottom: 1px solid var(--spice-text); } option { background-color: var(--spice-button); } .reddit-header { -webkit-box-pack: justify; -webkit-box-align: center; align-content: space-between; align-items: center; color: var(--spice-text); display: flex; justify-content: space-between; margin: 16px 0; } /* New layout top bar height = 64px + Original margin = 16px */ .Root__fixed-top-bar ~ .Root__main-view .reddit-header { margin-top: 80px; } .reddit-sort-bar { align-items: center; display: flex; } .reddit-sort-container { position: relative; display: flex; } .reddit-tabBar-headerItem { -webkit-app-region: no-drag; display: inline-block; pointer-events: auto; } .reddit-tabBar-headerItemLink { margin: 0 8px 0 0; } .reddit-tabBar-active { background-color: var(--spice-tab-active); border-radius: 4px; } .reddit-tabBar-headerItemLink { border-radius: 4px; color: var(--spice-text); display: inline-block; margin: 0 8px; padding: 8px 16px; position: relative; text-decoration: none !important; cursor: pointer; } .reddit-tabBar-nav { -webkit-app-region: drag; pointer-events: none; width: 100%; } .reddit-tabBar-headerItem .optionsMenu-dropBox { color: var(--spice-text); border: 0; max-width: 150px; height: 42px; padding: 0 30px 0 12px; background-color: initial; cursor: pointer; appearance: none; } .reddit-tabBar-headerItem .optionsMenu-dropBox svg { position: absolute; margin-left: 8px; } div.reddit-tabBar-headerItemLink { padding: 0; } .reddit-cardSubHeader { margin-top: 4px; white-space: normal; color: var(--spice-subtext); } .reddit-longDescription { display: flex; } .reddit-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton button { -webkit-tap-highlight-color: transparent; background-color: transparent; border: 0px; border-radius: 500px; display: inline-block; position: relative; touch-action: manipulation; transition-duration: 33ms; transition-property: background-color, border-color, color, box-shadow, filter, transform; user-select: none; vertical-align: middle; transform: translate3d(0px, 0px, 0px); padding: 0px; min-inline-size: 0px; align-self: center; } .reddit-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton span { -webkit-tap-highlight-color: transparent; position: relative; background-color: var(--spice-button-active); color: var(--spice-sidebar); display: flex; border-radius: 500px; font-size: inherit; min-block-size: 48px; -webkit-box-align: center; align-items: center; -webkit-box-pack: center; justify-content: center; inline-size: 48px; block-size: 48px; } .reddit-header + .main-gridContainer-gridContainer .main-card-card .main-playButton-PlayButton span:hover { transform: scale(1.04); } ================================================ FILE: Extensions/autoSkipExplicit.js ================================================ // NAME: Christian Spotify // AUTHOR: khanhas // DESCRIPTION: Auto skip explicit songs. Toggle in Profile menu. /// (async function ChristianSpotify() { if (!Spicetify.LocalStorage) { setTimeout(ChristianSpotify, 1000); return; } await new Promise((res) => Spicetify.Events.webpackLoaded.on(res)); let isEnabled = Spicetify.LocalStorage.get("ChristianMode") === "1"; new Spicetify.Menu.Item("Christian mode", isEnabled, (self) => { isEnabled = !isEnabled; Spicetify.LocalStorage.set("ChristianMode", isEnabled ? "1" : "0"); self.setState(isEnabled); }).register(); Spicetify.Player.addEventListener("songchange", () => { if (!isEnabled) return; const data = Spicetify.Player.data || Spicetify.Queue; if (!data) return; const isExplicit = data.item.metadata.is_explicit; if (isExplicit === "true") { Spicetify.Player.next(); } }); })(); ================================================ FILE: Extensions/autoSkipVideo.js ================================================ // NAME: Auto Skip Video // AUTHOR: khanhas // DESCRIPTION: Auto skip video /// (function SkipVideo() { Spicetify.Player.addEventListener("songchange", () => { const data = Spicetify.Player.data || Spicetify.Queue; if (!data) return; const meta = data.item.metadata; // Ads are also video media type so I need to exclude them out. if (meta["media.type"] === "video" && meta.is_advertisement !== "true") { Spicetify.Player.next(); } }); })(); ================================================ FILE: Extensions/bookmark.js ================================================ // NAME: Bookmark // AUTHOR: khanhas // VERSION: 2.0 // DESCRIPTION: Store page, track, track with time to view/listen later. /// (function Bookmark() { const { CosmosAsync, Player, LocalStorage, ContextMenu, URI } = Spicetify; if (!(CosmosAsync && URI)) { setTimeout(Bookmark, 300); return; } // UI Text const BUTTON_NAME_TEXT = "Bookmark"; const REMOVE_TEXT = "Remove"; // Local Storage keys const STORAGE_KEY = "bookmark_spicetify"; class BookmarkCollection { constructor() { const menu = createMenu(); this.container = menu.container; this.items = menu.menu; this.lastScroll = 0; this.container.onclick = () => { this.storeScroll(); this.container.remove(); }; this.filter = 0; this.apply(); } apply() { this.items.textContent = ""; // Remove all childs this.items.append(createMenuItem("Current page", storeThisPage)); this.items.append(createMenuItem("Track", storeTrack)); this.items.append(createMenuItem("Track with timestamp", storeTrackWithTime)); const select = createSortSelect(this.filter); select.onchange = (event) => { this.filter = event.srcElement.selectedIndex; this.apply(); }; this.items.append(select); const collection = this.getStorage(); for (const item of collection) { if (this.filter !== 0) { const isTrack = this.isTrack(item.uri); if (this.filter === 1 && isTrack) continue; if (this.filter === 2 && !isTrack) continue; } this.items.append(new CardContainer(item)); } } isTrack(uri) { return uri.startsWith("spotify:track:") || uri.startsWith("spotify:episode:"); } getStorage() { const storageRaw = LocalStorage.get(STORAGE_KEY); let storage = []; if (storageRaw) { storage = JSON.parse(storageRaw); } else { LocalStorage.set(STORAGE_KEY, "[]"); } return storage; } addToStorage(data) { data.id = `${data.uri}-${new Date().getTime()}`; /** @type {Object[]} */ const storage = this.getStorage(); storage.unshift(data); LocalStorage.set(STORAGE_KEY, JSON.stringify(storage)); this.apply(); } removeFromStorage(id) { const storage = this.getStorage().filter((item) => item.id !== id); LocalStorage.set(STORAGE_KEY, JSON.stringify(storage)); this.apply(); } changePosition(x, y) { this.items.style.left = `${x}px`; this.items.style.top = `${y + 40}px`; } storeScroll() { this.lastScroll = this.items.scrollTop; } setScroll() { this.items.scrollTop = this.lastScroll; } } class CardContainer extends HTMLElement { constructor(info) { super(); const uri = URI.fromString(info.uri); const isPlayable = uri.type === URI.Type.TRACK || uri.type === URI.Type.PLAYLIST_V2 || uri.type === URI.Type.ALBUM || uri.type === URI.Type.EPISODE || uri.type === URI.Type.PLAYLIST; this.innerHTML = `
${ info.imageUrl ? `${info.title}` : "" }
${info.title}
${info.description}
${ info.time ? `
${Player.formatTime(info.time)}
` : "" }
${ isPlayable ? `
` : "" }
`; Spicetify.Tippy(this.querySelectorAll("[data-tippy-content]"), Spicetify.TippyProps); if (isPlayable) { /** @type {HTMLButtonElement} */ const playButton = this.querySelector("button.main-playButton-PlayButton"); playButton.onclick = (event) => { onPlayClick(info); event.stopPropagation(); }; } /** @type {HTMLDivElement} */ const controls = this.querySelector(".bookmark-controls"); controls.onclick = (event) => { LIST.removeFromStorage(info.id); event.stopPropagation(); }; this.onclick = () => onLinkClick(info); } } customElements.define("bookmark-card-container", CardContainer); const LIST = new BookmarkCollection(); new Spicetify.Topbar.Button( BUTTON_NAME_TEXT, ``, (self) => { const bound = self.element.getBoundingClientRect(); LIST.changePosition(bound.left, bound.top); document.body.append(LIST.container); LIST.setScroll(); } ); /** * * @param {string} title * @param {() => void} callback */ function createMenuItem(title, callback) { const wrapper = document.createElement("div"); Spicetify.ReactDOM.render( Spicetify.React.createElement( Spicetify.ReactComponent.MenuItem, { onClick: () => { callback?.(); }, }, title ), wrapper ); return wrapper; } function createSortSelect(defaultOpt = 0) { const select = document.createElement("select"); select.className = "GlueDropdown bookmark-filter"; const allOpt = document.createElement("option"); allOpt.text = "All"; const pageOpt = document.createElement("option"); pageOpt.text = "Page"; const trackOpt = document.createElement("option"); trackOpt.text = "Track"; select.onclick = (ev) => ev.stopPropagation(); select.append(allOpt, pageOpt, trackOpt); select.options[defaultOpt].selected = true; return select; } async function storeThisPage() { let title; let description; let contextUri; const context = Spicetify.Platform.History.location.pathname; try { contextUri = Spicetify.URI.fromString(context); } catch (e) { Spicetify.showNotification("Cannot bookmark this page", true); return; } const uri = contextUri.toURI(); const titleElem = document.querySelector(".Root__main-view h1") || document.querySelector(".Root__main-view h2") || document.querySelector(".Root__main-view h3") || document.querySelector(".Root__main-view a"); if (titleElem) { title = titleElem.innerText; } if (!title && contextUri.type === URI.Type.APPLICATION) { title = idToProperName(contextUri.id); description = "Application"; } else { description = contextUri.type.replace(/-.+$/, ""); const tail = context.split("/"); if (tail.length > 3) { description += ` ${tail[3]}`; } description = idToProperName(description); } const headerElem = document.querySelector(".Root__main-view .main-entityHeader-background"); let imageUrl = headerElem?.style.backgroundImage.replace('url("', "").replace('")', ""); if (!imageUrl) { const firstImgElem = document.querySelector(".Root__main-view img"); imageUrl = firstImgElem?.src; } LIST.addToStorage({ uri, title, description, imageUrl, context, }); } function getTrackMeta() { const meta = { title: Player.data.item.metadata.title, imageUrl: Player.data.item.metadata.image_url, }; meta.uri = Player.data.item.uri; if (URI.isEpisode(meta.uri)) { meta.description = Player.data.item.metadata.album_title; } else { meta.description = Player.data.item.metadata.artist_name; } const playerState = Spicetify.Player.data; const contextUri = URI.fromString(playerState.context_uri ?? playerState.context.uri); if (contextUri && (contextUri.type === URI.Type.PLAYLIST || contextUri.type === URI.Type.PLAYLIST_V2 || contextUri.type === URI.Type.ALBUM)) { meta.context = `/${contextUri.toURLPath()}?uid=${Player.data.item.uid}`; } return meta; } function storeTrack() { LIST.addToStorage(getTrackMeta()); } function storeTrackWithTime() { const meta = getTrackMeta(); meta.time = Player.getProgress(); meta.progress = Player.getProgressPercent(); LIST.addToStorage(meta); } // Utilities function idToProperName(id) { const newId = id.replace(/-/g, " ").replace(/^.|\s./g, (char) => char.toUpperCase()); return newId; } function createMenu() { const container = document.createElement("div"); container.id = "bookmark-spicetify"; container.className = "context-menu-container"; container.style.zIndex = "1029"; const style = document.createElement("style"); style.textContent = ` #bookmark-spicetify { position: absolute; left: 0; right: 0; width: 100vw; height: 100vh; z-index: 5000; } #bookmark-menu { display: inline-block; width: 25%; min-width: 380px; max-height: 70%; overflow: hidden auto; padding-bottom: 10px; position: absolute; z-index: 5001; } .bookmark-card { display: flex; flex-direction: row; justify-content: flex-start; align-items: center; margin-top: 20px; cursor: pointer; padding: 0 10px; } .bookmark-card-image { width: 70px; height: 70px; object-fit: cover; object-position: center center; border-radius: 4px; } .bookmark-card-info { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; width: 100%; padding: 10px 20px; color: var(--spice-text); } .bookmark-card-info span { -webkit-line-clamp: 1; -webkit-box-orient: vertical; display: -webkit-box; white-space: normal; overflow: hidden; text-overflow: ellipsis; } .bookmark-filter { margin-top: 7px; margin-left: 8px; border-radius: 4px; padding: 0 8px 0 12px; height: 32px; align-items: center; background-color: transparent; border: 0; color: var(--spice-text); } .bookmark-controls { margin: 10px 0 10px 10px; width: 24px; height: 24px; align-items: center; background-color: rgba(var(--spice-rgb-shadow),.7); border: none; border-radius: 50%; color: var(--spice-text); cursor: pointer; display: inline-flex; justify-content: center; padding: 8px; } .bookmark-fixed-height { height: 30px; display: flex; align-items: center; } .bookmark-progress { overflow: hidden; width: 100px; height: 4px; border-radius: 2px; background-color: rgba(var(--spice-rgb-text), .2); } .bookmark-progress__bar { --progress: 0; width: calc(var(--progress) * 100%); height: 4px; background-color: var(--spice-text); } .bookmark-progress__time { padding-left: 5px; color: var(--spice-subtext); } `; const menu = document.createElement("ul"); menu.id = "bookmark-menu"; menu.className = "main-contextMenu-menu"; menu.onclick = (e) => e.stopPropagation(); container.append(style, menu); return { container, menu }; } /** * Handle Link click event when item context is a playlist */ async function onLinkClick(info) { if (info.context?.startsWith("/")) { Spicetify.Platform.History.push(info.context); return; } const url = Spicetify.URI.fromString(info.uri).toURLPath(true); Spicetify.Platform.History.push(url); } function onPlayClick(info) { let uri = info.uri; const options = {}; if (info.time) { options.seekTo = info.time; } if (info.context?.startsWith("/")) { uri = URI.fromString(info.context).toURI(); if (uri !== info.uri) { options.skipTo = {}; options.skipTo.uid = info.context.split("?uid=", 2)[1]; options.skipTo.uri = info.uri; } } Spicetify.Player.playUri(uri, {}, options); } const fetchAlbum = async (uri) => { const { getAlbum } = Spicetify.GraphQL.Definitions; const { data } = await Spicetify.GraphQL.Request(getAlbum, { uri, locale: Spicetify.Locale.getLocale(), offset: 0, limit: 10, }); const res = data.albumUnion; return { uri, title: res.name, description: "Album", imageUrl: res.coverArt.sources.reduce((prev, curr) => (prev.width > curr.width ? prev : curr)).url, }; }; const fetchShow = async (uri) => { const base62 = uri.split(":")[2]; const res = await CosmosAsync.get(`sp://core-show/v1/shows/${base62}?responseFormat=protobufJson`, { policy: { list: { index: true } }, }); return { uri, title: res.header.showMetadata.name, description: "Podcast", imageUrl: res.header.showMetadata.covers.standardLink, }; }; const fetchArtist = async (uri) => { const { queryArtistOverview } = Spicetify.GraphQL.Definitions; const { data } = await Spicetify.GraphQL.Request(queryArtistOverview, { uri, locale: Spicetify.Locale.getLocale(), includePrerelease: false, }); const res = data.artistUnion; return { uri, title: res.profile.name, description: "Artist", imageUrl: res.visuals.avatarImage?.sources.reduce((prev, curr) => (prev.width > curr.width ? prev : curr)).url || res.visuals.headerImage?.sources[0].url, }; }; const fetchTrack = async (uri, uid, context) => { const base62 = uri.split(":")[2]; const res = await CosmosAsync.get(`https://api.spotify.com/v1/tracks/${base62}`); let newContext; if (context && uid && Spicetify.URI.isPlaylistV1OrV2(context)) { newContext = `${Spicetify.URI.fromString(context).toURLPath(true)}?uid=${uid}`; } return { uri, title: res.name, description: res.artists[0].name, imageUrl: res.album.images[0].url, context: newContext ?? context, }; }; const fetchEpisode = async (uri) => { const base62 = uri.split(":")[2]; const res = await CosmosAsync.get(`https://api.spotify.com/v1/episodes/${base62}`); console.log(res); return { uri, title: res.name, description: `${res.show.name} episode`, imageUrl: res.show.images[0].url, }; }; const fetchPlaylist = async (uri) => { const res = await Spicetify.CosmosAsync.get(`sp://core-playlist/v1/playlist/${uri}/metadata`, { policy: { picture: true, name: true }, }); return { uri, title: res.metadata.name, description: "Playlist", imageUrl: res.metadata.picture, }; }; new Spicetify.ContextMenu.Item( "Bookmark", async ([uri], [uid] = [], context = undefined) => { const type = uri.split(":")[1]; let meta; switch (type) { case Spicetify.URI.Type.TRACK: meta = await fetchTrack(uri, uid, context); break; case Spicetify.URI.Type.ALBUM: meta = await fetchAlbum(uri); break; case Spicetify.URI.Type.ARTIST: meta = await fetchArtist(uri); break; case Spicetify.URI.Type.SHOW: meta = await fetchShow(uri); break; case Spicetify.URI.Type.EPISODE: meta = await fetchEpisode(uri); break; case Spicetify.URI.Type.PLAYLIST: case Spicetify.URI.Type.PLAYLIST_V2: meta = await fetchPlaylist(uri); break; } LIST.addToStorage(meta); }, ([uri]) => { const type = uri.split(":")[1]; switch (type) { case Spicetify.URI.Type.TRACK: case Spicetify.URI.Type.ALBUM: case Spicetify.URI.Type.ARTIST: case Spicetify.URI.Type.SHOW: case Spicetify.URI.Type.EPISODE: case Spicetify.URI.Type.PLAYLIST: case Spicetify.URI.Type.PLAYLIST_V2: return true; } return false; }, `` ).register(); })(); ================================================ FILE: Extensions/fullAppDisplay.js ================================================ // NAME: Full App Display // AUTHOR: khanhas // VERSION: 1.0 // DESCRIPTION: Fancy artwork and track status display. /// (function FullAppDisplay() { if (!Spicetify.Keyboard || !Spicetify.React || !Spicetify.ReactDOM) { setTimeout(FullAppDisplay, 200); return; } const { React: react, ReactDOM: reactDOM } = Spicetify; const { useState, useEffect, useRef } = react; const CONFIG = getConfig(); let updateVisual; const style = document.createElement("style"); const styleBase = ` #full-app-display { display: none; position: fixed; width: 100%; height: 100%; cursor: default; left: 0; top: 0; } #full-app-display.hide-cursor { cursor: none; } #fad-header { position: fixed; width: 100%; height: 80px; -webkit-app-region: drag; } #fad-body { height: 100vh; } #fad-foreground { position: relative; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; transform: scale(var(--fad-scale)); } #fad-art-image { position: relative; width: 100%; height: 100%; padding-bottom: 100%; border-radius: 15px; background-size: cover; } #fad-art-inner { position: absolute; left: 3%; bottom: 0; width: 94%; height: 94%; z-index: -1; backface-visibility: hidden; transform: translateZ(0); filter: blur(6px); backdrop-filter: blur(6px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6); } #fad-art-overlay { display: none; } #fad-art:hover #fad-art-overlay { position: absolute; width: 100%; height: 100%; display: flex; align-content: center; align-items: center; justify-content: center; border-radius: 15px; backdrop-filter: brightness(0.75); } #fad-heart { background-color: transparent; border: 0; color: #fff; padding: 0 5px; cursor: pointer; } #fad-progress-container { display: flex; align-items: center; justify-content: center; flex-grow: 1; gap: 10px; } #fad-progress { width: 100%; height: 6px; border-radius: 6px; background-color: #ffffff50; flex-grow: 1; min-width: 150px; } #fad-progress:hover #fad-progress-inner { background-color: var(--spice-button); } #fad-progress:hover #fad-thumb { visibility: visible; } #fad-progress-inner { width: var(--progress-width); height: 100%; border-radius: 6px; background-color: #ffffff; box-shadow: 4px 0 12px rgba(0, 0, 0, 0.8); position: relative; } #fad-thumb { position: absolute; top: -3px; right: -6px; width: 12px; height: 12px; border-radius: 50%; background-color: #ffffff; cursor: pointer; visibility: hidden; } #fad-background { position: absolute; left: 0; top: 0; width: 100%; height: 100%; z-index: -2; } body.fad-activated #full-app-display { display: block } .fad-background-fade { transition: background-image 1s linear; } body.video-full-screen.video-full-screen--hide-ui { cursor: auto; } #fad-controls button { background-color: transparent; border: 0; color: currentColor; padding: 0 5px; cursor: pointer; } #fad-controls button svg { vertical-align: middle; } #fad-elapsed, #fad-duration { font-variant-numeric: tabular-nums; } #fad-artist svg, #fad-album svg, #fad-release-date svg { display: inline-block; } ::-webkit-scrollbar { width: 8px; } `; const styleChoices = [ ` #fad-foreground { flex-direction: row; text-align: left; } #fad-art { width: calc(100vw - 840px); min-width: 200px; max-width: 340px; } #fad-details { padding-left: 50px; line-height: initial; max-width: 70%; color: #FFFFFF; } #fad-title { font-size: 87px; font-weight: var(--glue-font-weight-black); } #fad-artist, #fad-album, #fad-release-date { font-size: 54px; font-weight: var(--glue-font-weight-medium); } #fad-artist svg, #fad-album svg, #fad-release-date svg { margin-right: 5px; } #fad-status { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; } #fad-status.active { margin-top: 20px; } #fad-controls { display: flex; margin: 0 auto; }`, ` #fad-art { width: calc(100vh - 400px); max-width: 340px; } #fad-foreground { flex-direction: column; text-align: center; } #fad-details { padding-top: 50px; line-height: initial; max-width: 70%; color: #FFFFFF; } #fad-title { font-size: 54px; font-weight: var(--glue-font-weight-black); } #fad-artist, #fad-album, #fad-release-date { font-size: 33px; font-weight: var(--glue-font-weight-medium); } #fad-artist svg, #fad-album svg, #fad-release-date svg { width: 25px; height: 25px; margin-right: 5px; } #fad-status { display: flex; min-width: 400px; max-width: 400px; align-items: center; flex-direction: column; } #fad-status.active { margin: 20px auto 0; } #fad-controls { margin-top: 20px; order: 2 } #fad-progress-container { width: 100%; }`, ]; const lyricsPlusBase = ` #fad-body { display: grid; grid-template-columns: 1fr 1fr; } #fad-foreground { padding: 0 50px 0 100px; width: 50vw; } #fad-lyrics-plus-container { position: relative; width: 50vw; } `; const lyricsPlusStyleChoices = [ ` #fad-title { font-size: 54px; } #fad-art { max-width: 210px; }`, "", ]; updateStyle(); const DisplayIcon = ({ icon, size }) => { return react.createElement("svg", { width: size, height: size, viewBox: "0 0 16 16", fill: "currentColor", dangerouslySetInnerHTML: { __html: icon, }, }); }; const SubInfo = ({ text, id, icon }) => { return react.createElement( "div", { id, }, CONFIG.icons && react.createElement(DisplayIcon, { icon, size: 35 }), react.createElement("span", null, text) ); }; const ButtonIcon = ({ icon, onClick }) => { return react.createElement( "button", { onClick, }, react.createElement(DisplayIcon, { icon, size: 20 }) ); }; const ProgressBar = () => { const [progress, setProgress] = useState(Spicetify.Player.getProgress()); const duration = Spicetify.Platform.PlayerAPI._state.duration; const progressDivRef = useRef(null); const [isDragging, setIsDragging] = useState(false); useEffect(() => { if (isDragging) { return; } const update = ({ data }) => setProgress(data); Spicetify.Player.addEventListener("onprogress", update); return () => Spicetify.Player.removeEventListener("onprogress", update); }, [isDragging]); // Handle click on progress bar to set progress const handleClick = (e) => { const container = progressDivRef.current; if (isDragging || !container) { return; } const containerRect = container.getBoundingClientRect(); const clickX = e.clientX - containerRect.left; const newProgress = (clickX / containerRect.width) * duration; Spicetify.Player.seek(newProgress); setProgress(newProgress); }; // Handle dragging functionality const handleMouseDown = () => setIsDragging(true); const handleMouseMove = (e) => { const container = progressDivRef.current; if (!isDragging || !container) { return; } const containerRect = container.getBoundingClientRect(); const offsetX = e.clientX - containerRect.left; const newProgress = (offsetX / containerRect.width) * duration; setProgress(newProgress); }; const handleMouseUp = () => { if (!isDragging) { return; } Spicetify.Player.seek(progress); setIsDragging(false); }; // Attach mousemove and mouseup listeners when dragging starts useEffect(() => { if (isDragging) { window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); } else { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); } return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, [isDragging]); // Calculate the thumb position const thumbPosition = (progress / duration) * 100; return react.createElement( "div", { id: "fad-progress-container" }, react.createElement("span", { id: "fad-elapsed" }, Spicetify.Player.formatTime(progress)), react.createElement( "div", { id: "fad-progress", ref: progressDivRef, onClick: handleClick, style: { "--progress-width": `${thumbPosition}%`, }, }, react.createElement( "div", { id: "fad-progress-inner" }, react.createElement("div", { id: "fad-thumb", onMouseDown: handleMouseDown, }) ) ), react.createElement("span", { id: "fad-duration" }, Spicetify.Player.formatTime(duration)) ); }; const PlayerControls = () => { const [value, setValue] = useState(Spicetify.Player.isPlaying()); useEffect(() => { const update = ({ data }) => setValue(!data.isPaused); Spicetify.Player.addEventListener("onplaypause", update); return () => Spicetify.Player.removeEventListener("onplaypause", update); }); return react.createElement( "div", { id: "fad-controls" }, react.createElement(ButtonIcon, { icon: Spicetify.SVGIcons["skip-back"], onClick: Spicetify.Player.back, }), react.createElement(ButtonIcon, { icon: Spicetify.SVGIcons[value ? "pause" : "play"], onClick: Spicetify.Player.togglePlay, }), react.createElement(ButtonIcon, { icon: Spicetify.SVGIcons["skip-forward"], onClick: Spicetify.Player.next, }) ); }; class FAD extends react.Component { constructor(props) { super(props); this.state = { title: "", artist: "", album: "", releaseDate: "", cover: "", heart: Spicetify.Player.getHeart(), }; this.currTrackImg = new Image(); this.nextTrackImg = new Image(); this.mousetrap = new Spicetify.Mousetrap(); } async getAlbumDate(uri) { const id = uri.replace("spotify:album:", ""); const albumInfo = await Spicetify.CosmosAsync.get(`https://api.spotify.com/v1/albums/${id}`); const albumDate = new Date(albumInfo.release_date); return albumDate.toLocaleString("default", { year: "numeric", month: "short", day: "numeric", }); } async fetchInfo() { const meta = Spicetify.Player.data.item.metadata; // prepare title let rawTitle = meta.title; if (CONFIG.trimTitle) { rawTitle = rawTitle .replace(/\(.+?\)/g, "") .replace(/\[.+?\]/g, "") .replace(/\s-\s.+?$/, "") .replace(/,.+?$/, "") .trim(); } // prepare artist let artistName; if (CONFIG.showAllArtists) { artistName = Object.keys(meta) .filter((key) => key.startsWith("artist_name")) .sort() .map((key) => meta[key]) .join(", "); } else { artistName = meta.artist_name; } // prepare release date let releaseDate; if (CONFIG.showReleaseDate) { const albumURI = meta.album_uri; if (albumURI?.startsWith("spotify:album:")) { releaseDate = await this.getAlbumDate(albumURI); } } // prepare album const albumText = meta.album_title || ""; if (meta.image_xlarge_url === this.currTrackImg.src) { this.setState({ title: rawTitle || "", artist: artistName || "", album: albumText || "", releaseDate: releaseDate || "", heart: Spicetify.Player.getHeart(), }); return; } // TODO: Pre-load next track // Wait until next track image is downloaded then update UI text and images const previousImg = this.currTrackImg.cloneNode(); this.currTrackImg.src = meta.image_xlarge_url; this.currTrackImg.onload = () => { const bgImage = `url("${this.currTrackImg.src}")`; this.animateCanvas(previousImg, this.currTrackImg); this.setState({ title: rawTitle || "", artist: artistName || "", album: albumText || "", releaseDate: releaseDate || "", cover: bgImage, heart: Spicetify.Player.getHeart(), }); }; this.currTrackImg.onerror = () => { // Placeholder this.currTrackImg.src = "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCI+CiAgPHJlY3Qgc3R5bGU9ImZpbGw6I2ZmZmZmZiIgd2lkdGg9IjQwIiBoZWlnaHQ9IjQwIiB4PSIwIiB5PSIwIiAvPgogIDxwYXRoIGZpbGw9IiNCM0IzQjMiIGQ9Ik0yNi4yNSAxNi4xNjJMMjEuMDA1IDEzLjEzNEwyMS4wMTIgMjIuNTA2QzIwLjU5NCAyMi4xOTIgMjAuMDgxIDIxLjk5OSAxOS41MTkgMjEuOTk5QzE4LjE0MSAyMS45OTkgMTcuMDE5IDIzLjEyMSAxNy4wMTkgMjQuNDk5QzE3LjAxOSAyNS44NzggMTguMTQxIDI2Ljk5OSAxOS41MTkgMjYuOTk5QzIwLjg5NyAyNi45OTkgMjIuMDE5IDI1Ljg3OCAyMi4wMTkgMjQuNDk5QzIyLjAxOSAyNC40MjIgMjIuMDA2IDE0Ljg2NyAyMi4wMDYgMTQuODY3TDI1Ljc1IDE3LjAyOUwyNi4yNSAxNi4xNjJaTTE5LjUxOSAyNS45OThDMTguNjkyIDI1Ljk5OCAxOC4wMTkgMjUuMzI1IDE4LjAxOSAyNC40OThDMTguMDE5IDIzLjY3MSAxOC42OTIgMjIuOTk4IDE5LjUxOSAyMi45OThDMjAuMzQ2IDIyLjk5OCAyMS4wMTkgMjMuNjcxIDIxLjAxOSAyNC40OThDMjEuMDE5IDI1LjMyNSAyMC4zNDYgMjUuOTk4IDE5LjUxOSAyNS45OThaIi8+Cjwvc3ZnPgo="; }; } animateCanvas(prevImg, nextImg) { const { innerWidth: width, innerHeight: height } = window; this.back.width = width; this.back.height = height; const ctx = this.back.getContext("2d"); ctx.imageSmoothingEnabled = false; ctx.filter = "blur(30px) brightness(0.6)"; const blur = 30; const x = -blur * 2; let y; let dim; if (width > height) { dim = width; y = x - (width - height) / 2; } else { dim = height; y = x; } const size = dim + 4 * blur; if (!CONFIG.enableFade) { ctx.globalAlpha = 1; ctx.drawImage(nextImg, x, y, size, size); return; } let factor = 0.0; const animate = () => { ctx.globalAlpha = 1; ctx.drawImage(prevImg, x, y, size, size); ctx.globalAlpha = Math.sin((Math.PI / 2) * factor); ctx.drawImage(nextImg, x, y, size, size); if (factor < 1.0) { factor += 0.016; requestAnimationFrame(animate); } }; requestAnimationFrame(animate); } componentDidMount() { this.updateInfo = this.fetchInfo.bind(this); Spicetify.Player.addEventListener("songchange", this.updateInfo); this.updateInfo(); updateVisual = () => { updateStyle(); this.fetchInfo(); }; this.onQueueChange = async (queueData) => { const queue = queueData.data; let nextTrack; if (queue.queued.length) { nextTrack = queue.queued[0]; } else { nextTrack = queue.nextUp[0]; } this.nextTrackImg.src = nextTrack.metadata.image_xlarge_url; }; const scaleLimit = { min: 0.1, max: 4, step: 0.05 }; this.onScaleChange = (event) => { if (!event.ctrlKey) return; const dir = event.deltaY < 0 ? 1 : -1; let temp = (CONFIG.scale || 1) + dir * scaleLimit.step; if (temp < scaleLimit.min) { temp = scaleLimit.min; } else if (temp > scaleLimit.max) { temp = scaleLimit.max; } CONFIG.scale = temp; saveConfig(); updateVisual(); }; Spicetify.Platform.PlayerAPI._events.addListener("queue_update", this.onQueueChange); this.mousetrap.bind("esc", deactivate); window.dispatchEvent(new Event("fad-request")); } componentWillUnmount() { Spicetify.Player.removeEventListener("songchange", this.updateInfo); Spicetify.Platform.PlayerAPI._events.removeListener("queue_update", this.onQueueChange); this.mousetrap.unbind("esc"); } render() { return react.createElement( "div", { id: "full-app-display", className: "Video VideoPlayer--fullscreen VideoPlayer--landscape", onDoubleClick: deactivate, onContextMenu: openConfig, }, react.createElement("canvas", { id: "fad-background", ref: (el) => { this.back = el; }, }), react.createElement("div", { id: "fad-header" }), react.createElement( "div", { id: "fad-body" }, react.createElement( "div", { id: "fad-foreground", style: { "--fad-scale": CONFIG.scale || 1, }, ref: (el) => { if (!el) return; el.onmousewheel = this.onScaleChange; }, }, react.createElement( "div", { id: "fad-art" }, react.createElement( "div", { id: "fad-art-image", className: CONFIG.enableFade && "fad-background-fade", style: { backgroundImage: this.state.cover, }, }, react.createElement( "div", { id: "fad-art-overlay", }, react.createElement( "button", { id: "fad-heart", onClick: () => { Spicetify.Player.toggleHeart(); this.setState({ heart: !this.state.heart }); }, }, react.createElement(DisplayIcon, { icon: Spicetify.SVGIcons[this.state.heart ? "heart-active" : "heart"], size: 50, }) ) ), react.createElement("div", { id: "fad-art-inner", }) ) ), react.createElement( "div", { id: "fad-details" }, react.createElement("div", { id: "fad-title" }, this.state.title), react.createElement(SubInfo, { id: "fad-artist", text: this.state.artist, icon: Spicetify.SVGIcons.artist, }), CONFIG.showAlbum && react.createElement(SubInfo, { id: "fad-album", text: this.state.album, icon: Spicetify.SVGIcons.album, }), CONFIG.showReleaseDate && react.createElement(SubInfo, { id: "fad-release-date", text: this.state.releaseDate, icon: Spicetify.SVGIcons.clock, }), react.createElement( "div", { id: "fad-status", className: (CONFIG.enableControl || CONFIG.enableProgress) && "active", }, CONFIG.enableControl && react.createElement(PlayerControls), CONFIG.enableProgress && react.createElement(ProgressBar) ) ) ), CONFIG.lyricsPlus && react.createElement("div", { id: "fad-lyrics-plus-container", style: { "--lyrics-color-active": "#ffffff", "--lyrics-color-inactive": "#ffffff50", }, }) ) ); } } const classes = ["video", "video-full-screen", "video-full-window", "video-full-screen--hide-ui", "fad-activated"]; const container = document.createElement("div"); container.id = "fad-main"; let lastApp; let cursorTimeout; let fad; async function toggleFullscreen() { if (CONFIG.enableFullscreen) { await document.documentElement.requestFullscreen(); toggleCursor(false); } else if (document.webkitIsFullScreen) { await document.exitFullscreen(); toggleCursor(true); } } function eventListener() { showCursor(); cursorTimeout = setTimeout(hideCursor, 2000); } function showCursor() { fad.classList.remove("hide-cursor"); clearTimeout(cursorTimeout); } function hideCursor() { fad.classList.add("hide-cursor"); } function toggleCursor(show = true) { fad = document.getElementById("full-app-display"); if (!fad) { setTimeout(toggleCursor, 300, show); return; } if (show) { document.removeEventListener("mousemove", eventListener); showCursor(); } else { cursorTimeout = setTimeout(hideCursor, 2000); document.addEventListener("mousemove", eventListener); } } async function activate() { if (!Spicetify.Player.data) return; await toggleFullscreen(); document.body.classList.add(...classes); document.body.append(style, container); reactDOM.render(react.createElement(FAD), container); requestLyricsPlus(); } function deactivate() { if (CONFIG.enableFullscreen || document.webkitIsFullScreen) { document.exitFullscreen(); } toggleCursor(true); document.body.classList.remove(...classes); reactDOM.unmountComponentAtNode(container); style.remove(); container.remove(); window.dispatchEvent(new Event("fad-request")); if (lastApp && lastApp !== "/lyrics-plus") { Spicetify.Platform.History.push(lastApp); } } function toggleFad() { if (document.body.classList.contains("fad-activated")) { deactivate(); } else { activate(); } } function updateStyle() { style.innerHTML = styleBase + styleChoices[CONFIG.vertical ? 1 : 0] + (checkLyricsPlus() && CONFIG.lyricsPlus ? lyricsPlusBase + lyricsPlusStyleChoices[CONFIG.vertical ? 1 : 0] : ""); } function checkLyricsPlus() { return Spicetify.Config?.custom_apps?.includes("lyrics-plus") || !!document.querySelector("a[href='/lyrics-plus']"); } function requestLyricsPlus() { if (CONFIG.lyricsPlus && checkLyricsPlus()) { lastApp = Spicetify.Platform.History.location.pathname; if (lastApp !== "/lyrics-plus") { Spicetify.Platform.History.push("/lyrics-plus"); } } window.dispatchEvent(new Event("fad-request")); } function getConfig() { try { const parsed = JSON.parse(Spicetify.LocalStorage.get("full-app-display-config") || "{}"); if (parsed && typeof parsed === "object") { return parsed; } throw ""; } catch { Spicetify.LocalStorage.set("full-app-display-config", "{}"); return {}; } } function saveConfig() { Spicetify.LocalStorage.set("full-app-display-config", JSON.stringify(CONFIG)); } const ConfigItem = ({ name, field, func, disabled = false }) => { const [value, setValue] = useState(CONFIG[field]); return react.createElement( "div", { className: "setting-row" }, react.createElement("label", { className: "col description" }, name), react.createElement( "div", { className: "col action" }, react.createElement( "button", { className: `switch${value ? "" : " disabled"}`, disabled, onClick: () => { const state = !value; CONFIG[field] = state; setValue(state); saveConfig(); func(); }, }, react.createElement(DisplayIcon, { icon: Spicetify.SVGIcons.check, size: 16, }) ) ) ); }; function openConfig(event) { event.preventDefault(); const style = react.createElement("style", { dangerouslySetInnerHTML: { __html: ` .setting-row::after { content: ""; display: table; clear: both; } .setting-row .col { display: flex; padding: 10px 0; align-items: center; } .setting-row .col.description { float: left; padding-right: 15px; } .setting-row .col.action { float: right; text-align: right; } button.switch { align-items: center; border: 0px; border-radius: 50%; background-color: rgba(var(--spice-rgb-shadow), .7); color: var(--spice-text); cursor: pointer; display: flex; margin-inline-start: 12px; padding: 8px; } button.switch.disabled, button.switch[disabled] { color: rgba(var(--spice-rgb-text), .3); } `, }, }); const configContainer = react.createElement( "div", null, style, react.createElement(ConfigItem, { name: checkLyricsPlus() ? "Enable Lyrics Plus integration" : "Lyrics Plus not applied", field: "lyricsPlus", func: () => { updateVisual(); requestLyricsPlus(); }, disabled: !checkLyricsPlus(), }), react.createElement(ConfigItem, { name: "Enable progress bar", field: "enableProgress", func: updateVisual, }), react.createElement(ConfigItem, { name: "Enable controls", field: "enableControl", func: updateVisual, }), react.createElement(ConfigItem, { name: "Trim title", field: "trimTitle", func: updateVisual, }), react.createElement(ConfigItem, { name: "Show album", field: "showAlbum", func: updateVisual, }), react.createElement(ConfigItem, { name: "Show all artists", field: "showAllArtists", func: updateVisual, }), react.createElement(ConfigItem, { name: "Show release date", field: "showReleaseDate", func: updateVisual, }), react.createElement(ConfigItem, { name: "Show icons", field: "icons", func: updateVisual, }), react.createElement(ConfigItem, { name: "Vertical mode", field: "vertical", func: updateStyle, }), react.createElement(ConfigItem, { name: "Enable fullscreen", field: "enableFullscreen", func: toggleFullscreen, }), react.createElement(ConfigItem, { name: "Enable song change animation", field: "enableFade", func: updateVisual, }) ); Spicetify.PopupModal.display({ title: "Full App Display", content: configContainer, }); } // Add activator on top bar new Spicetify.Topbar.Button( "Full App Display", `${Spicetify.SVGIcons.projector}`, activate ); Spicetify.Mousetrap.bind("f11", toggleFad); })(); ================================================ FILE: Extensions/keyboardShortcut.js ================================================ // NAME: Keyboard Shortcut // AUTHOR: khanhas, OhItsTom // DESCRIPTION: Register a few more keybinds to support keyboard-driven navigation in Spotify client. /// (function KeyboardShortcut() { if (!Spicetify.Mousetrap) { setTimeout(KeyboardShortcut, 1000); return; } // Variables / Conditions const vim = new VimBind(); const SCROLL_STEP = 25; /** * Binds a keyboard shortcut using Mousetrap. * @param {string} key - The Mousetrap keybind. * @param {boolean | undefined} staticCondition - A static condition. * @param {(event: KeyboardEvent) => void} callback - Callback function for the event. */ const binds = { // Shutdown Spotify using Ctrl+Q "ctrl+q": { callback: () => Spicetify.CosmosAsync.post("sp://esperanto/spotify.desktop.lifecycle_esperanto.proto.DesktopLifecycle/Shutdown") && Spicetify.CosmosAsync.post("sp://desktop/v1/shutdown"), }, // Rotate through sidebar items using Ctrl+Tab and Ctrl+Shift+Tab "ctrl+tab": { callback: () => rotateSidebar(1) }, "ctrl+shift+tab": { callback: () => rotateSidebar(-1) }, // Focus on the app content before scrolling using Shift+PageUp and Shift+PageDown "shift+pageup": { callback: () => focusOnApp() }, "shift+pagedown": { callback: () => focusOnApp() }, // Scroll actions using 'j' and 'k' keys j: { callback: () => createScrollCallback(SCROLL_STEP) }, k: { callback: () => createScrollCallback(-SCROLL_STEP) }, // Scroll to the top ('gg') or bottom ('Shift+g') of the page "g g": { callback: () => scrollToPosition(0) }, "shift+g": { callback: () => scrollToPosition(1) }, // Shift + H and Shift + L to go back and forward page "shift+h": { callback: () => Spicetify.Platform.History.goBack() }, "shift+l": { callback: () => Spicetify.Platform.History.goForward() }, // M to Like/Unlike track m: { callback: () => Spicetify.Player.toggleHeart() }, // Forward Slash to open search page "/": { callback: () => Spicetify.Platform.History.replace("/search") }, // CTRL + Arrow Left Next and CTRL + Arrow Right Previous Song "ctrl+left": { callback: () => Spicetify.Player.back() }, "ctrl+right": { callback: () => Spicetify.Player.next() }, // CTRL + Arrow Up Increase Volume CTRL + Arrow Down Decrease Volume "ctrl+up": { callback: () => Spicetify.Player.setVolume(Spicetify.Player.getVolume() + 0.05) }, "ctrl+down": { callback: () => Spicetify.Player.setVolume(Spicetify.Player.getVolume() - 0.05) }, // Activate Vim mode and set cancel key to 'ESCAPE' f: { callback: (event) => { vim.activate(event); vim.setCancelKey("ESCAPE"); }, }, }; // Bind all the keys for (const [key, { staticCondition, callback }] of Object.entries(binds)) { if (typeof staticCondition === "undefined" || staticCondition) { Spicetify.Mousetrap.bind(key, (event) => { event.preventDefault(); if (!vim.isActive) { callback(event); } }); } } // re-render vim on window resize & prevent mouse event while active window.addEventListener( "resize", (event) => { if (vim.isActive) { vim.activate(); } }, true ); window.addEventListener( "mousedown", (event) => { if (vim.isActive) { event.stopPropagation(); } }, true ); // Functions function focusOnApp() { return document.querySelector( ".Root__main-view .os-viewport, .Root__main-view .main-view-container > .main-view-container__scroll-node:not([data-overlayscrollbars-initialize]), .Root__main-view .main-view-container__scroll-node > [data-overlayscrollbars-viewport]" ); } function createScrollCallback(step) { const app = focusOnApp(); if (app) { const scrollInterval = setInterval(() => { app.scrollTop += step; }, 10); document.addEventListener("keyup", () => { clearInterval(scrollInterval); }); } } function scrollToPosition(position) { const app = focusOnApp(); app.scroll(0, position === 0 ? 0 : app.scrollHeight); } /** * @returns {number | undefined} * @param {NodeListOf} allItems */ function findActiveIndex(allItems) { const activeLink = document.querySelector(".main-yourLibraryX-navLinkActive"); const historyURI = Spicetify.Platform.History.location.pathname.replace(/^\//, "spotify:").replace(/\//g, ":"); const activePage = document.querySelector(`[aria-describedby="onClickHint${historyURI}"]`); if (!activeLink && !activePage) { return -1; } let index = 0; for (const item of allItems) { if (item === activeLink || item === activePage) { return index; } index++; } } /** * * @param {1 | -1} direction */ function rotateSidebar(direction) { const allItems = document.querySelectorAll( "#spicetify-sticky-list .main-yourLibraryX-navLink, .main-yourLibraryX-listItem > div:not(:has([data-skip-in-keyboard-nav])) > div:first-child" ); const maxIndex = allItems.length - 1; let index = findActiveIndex(allItems) + direction; if (index < 0) index = maxIndex; else if (index > maxIndex) index = 0; allItems[index].click(); } })(); function VimBind() { const elementQuery = ["[href]", "button", ".main-trackList-trackListRow", "[role='button']"].join(","); const keyList = "qwertasdfgzxcvyuiophjklbnm".split(""); const lastKeyIndex = keyList.length - 1; this.isActive = false; const vimOverlay = document.createElement("div"); const baseOverlay = document.createElement("div"); const tippyOverlay = document.createElement("div"); vimOverlay.id = "vim-overlay"; baseOverlay.id = "base-overlay"; tippyOverlay.id = "tippy-overlay"; vimOverlay.style.position = baseOverlay.style.position = tippyOverlay.style.position = "absolute"; vimOverlay.style.width = baseOverlay.style.width = tippyOverlay.style.width = "100%"; vimOverlay.style.height = baseOverlay.style.height = tippyOverlay.style.height = "100%"; baseOverlay.style.zIndex = "9999"; tippyOverlay.style.zIndex = "10000"; vimOverlay.style.display = "none"; vimOverlay.innerHTML = ``; vimOverlay.append(baseOverlay); vimOverlay.append(tippyOverlay); document.body.append(vimOverlay); const mousetrap = new Spicetify.Mousetrap(document); mousetrap.bind(keyList, listenToKeys.bind(this), "keypress"); // Pause mousetrap event emitter const orgStopCallback = mousetrap.stopCallback; mousetrap.stopCallback = () => true; /** * * @param {KeyboardEvent} event */ this.activate = function (event) { vimOverlay.style.display = "block"; const vimkey = getVims(); if (vimkey.length > 0) { for (const e of vimkey) { e.remove(); } } let firstKey = 0; let secondKey = 0; for (const e of getLinks()) { const computed = window.getComputedStyle(e); if (computed.display === "none" || computed.visibility === "hidden" || computed.opacity === "0") { continue; } const bound = e.getBoundingClientRect(); const owner = document.body; let top = bound.top; let left = bound.left; if ( bound.bottom > owner.clientHeight || bound.left > owner.clientWidth || bound.right < 0 || bound.top < 0 || bound.width === 0 || bound.height === 0 ) { continue; } // Exclude certain elements from the centering calculation if (e.parentNode.role !== "row") { top = top + bound.height / 2 - 15; left = left + bound.width / 2 - 15; } // Append the key to the correct overlay if (e.tagName === "BUTTON" && e.parentNode.tagName === "LI") { tippyOverlay.append(createKey(e, keyList[firstKey] + keyList[secondKey], top, left)); } else { baseOverlay.append(createKey(e, keyList[firstKey] + keyList[secondKey], top, left)); } secondKey++; if (secondKey > lastKeyIndex) { secondKey = 0; firstKey++; } } this.isActive = true; setTimeout(() => { mousetrap.stopCallback = orgStopCallback.bind(mousetrap); }, 100); }; /** * * @param {KeyboardEvent} event */ this.deactivate = function (event) { mousetrap.stopCallback = () => true; this.isActive = false; vimOverlay.style.display = "none"; for (const e of getVims()) { e.remove(); } }; function getLinks() { const elements = Array.from(document.querySelectorAll(elementQuery)); return elements; } function getVims() { return Array.from(vimOverlay.getElementsByClassName("vim-key")); } /** * @param {KeyboardEvent} event */ function listenToKeys(event) { if (!this.isActive) { return; } const vimkey = getVims(); if (vimkey.length === 0) { this.deactivate(event); return; } for (const div of vimkey) { const text = div.innerText.toLowerCase(); if (text[0] !== event.key) { div.remove(); continue; } const newText = text.slice(1); if (newText.length === 0) { interact(div.target); this.deactivate(event); return; } div.innerText = newText; } if (baseOverlay.childNodes.length === 0 && tippyOverlay.childNodes.length === 0) { this.deactivate(event); } } /** * @param {HTMLElement} element */ function interact(element) { // Hover on contextmenu dropdown list items if (element.tagName === "BUTTON" && element.parentNode.tagName === "LI" && element.ariaExpanded !== null) { const event = new MouseEvent("mouseover", { view: window, bubbles: true, cancelable: true, }); element.dispatchEvent(event); return; } if (element.hasAttribute("href") || element.tagName === "BUTTON" || element.role === "button" || element.parentNode.role === "row") { element.click(); return; } const findButton = element.querySelector(`button[data-ta-id="play-button"]`) || element.querySelector(`button[data-button="play"]`); if (findButton instanceof HTMLButtonElement) { findButton.click(); return; } alert("Let me know where you found this button, please. I can't click this for you without that information."); return; } /** * @param {Element} target * @param {string} key * @param {string | number} top * @param {string | number} left */ function createKey(target, key, top, left) { const div = document.createElement("span"); div.classList.add("vim-key"); div.innerText = key; div.style.top = `${top}px`; div.style.left = `${left}px`; div.target = target; return div; } /** * * @param {Spicetify.Keyboard.ValidKey} key */ this.setCancelKey = function (key) { mousetrap.bind(Spicetify.Keyboard.KEYS[key], this.deactivate.bind(this)); }; return this; } ================================================ FILE: Extensions/loopyLoop.js ================================================ // NAME: Loopy loop // AUTHOR: khanhas // VERSION: 0.1 // DESCRIPTION: Simple tool to help you practice hitting that note right. Right click at process bar to open up menu. /// (function LoopyLoop() { const playbackBar = document.querySelector(".playback-bar"); const progressContainer = playbackBar?.querySelector(".playback-progressbar-container"); const rangeInput = progressContainer?.querySelector('input[type="range"]'); const bar = rangeInput?.closest("label")?.nextElementSibling; if (!(bar && Spicetify.Player)) { setTimeout(LoopyLoop, 100); return; } const style = document.createElement("style"); style.innerHTML = ` #loopy-loop-start, #loopy-loop-end { position: absolute; font-weight: bolder; font-size: 15px; top: -7px; } `; const startMark = document.createElement("div"); startMark.id = "loopy-loop-start"; startMark.innerText = "["; const endMark = document.createElement("div"); endMark.id = "loopy-loop-end"; endMark.innerText = "]"; startMark.style.position = endMark.style.position = "absolute"; startMark.hidden = endMark.hidden = true; bar.append(style); bar.append(startMark); bar.append(endMark); let start = null; let end = null; let mouseOnBarPercent = 0.0; function drawOnBar() { if (start === null && end === null) { startMark.hidden = endMark.hidden = true; return; } startMark.hidden = endMark.hidden = false; startMark.style.left = `${start * 100}%`; endMark.style.left = `${end * 100}%`; } function reset() { start = null; end = null; drawOnBar(); } let debouncing = 0; Spicetify.Player.addEventListener("onprogress", (event) => { if (start != null && end != null) { if (debouncing) { if (event.timeStamp - debouncing > 1000) { debouncing = 0; } return; } const percent = Spicetify.Player.getProgressPercent(); if (percent > end || percent < start) { debouncing = event.timeStamp; Spicetify.Player.seek(start); return; } } }); Spicetify.Player.addEventListener("songchange", reset); function createMenuItem(title, callback) { const item = document.createElement("li"); item.setAttribute("role", "menuitem"); const button = document.createElement("button"); button.classList.add("main-contextMenu-menuItemButton"); button.textContent = title; button.onclick = () => { contextMenu.hidden = true; callback?.(); }; item.append(button); return item; } const startBtn = createMenuItem("Set start", () => { start = mouseOnBarPercent; if (end === null || start > end) { end = 0.99; } drawOnBar(); }); const endBtn = createMenuItem("Set end", () => { end = mouseOnBarPercent; if (start === null || end < start) { start = 0; } drawOnBar(); }); const resetBtn = createMenuItem("Reset", reset); const contextMenu = document.createElement("div"); contextMenu.id = "loopy-context-menu"; contextMenu.innerHTML = `
    `; contextMenu.style.position = "absolute"; contextMenu.firstElementChild.append(startBtn, endBtn, resetBtn); document.body.append(contextMenu); const { height: contextMenuHeight } = contextMenu.getBoundingClientRect(); contextMenu.hidden = true; window.addEventListener("click", () => { contextMenu.hidden = true; }); progressContainer.oncontextmenu = (event) => { const { x, width } = bar.getBoundingClientRect(); mouseOnBarPercent = Math.max(0, Math.min(1, (event.clientX - x) / width)); contextMenu.style.transform = `translate(${event.clientX}px,${event.clientY - contextMenuHeight}px)`; contextMenu.hidden = false; event.preventDefault(); }; })(); ================================================ FILE: Extensions/popupLyrics.js ================================================ // NAME: Popup Lyrics // AUTHOR: khanhas // Netease API parser and UI from https://github.com/mantou132/Spotify-Lyrics // DESCRIPTION: Pop lyrics up /// if (!navigator.serviceWorker) { // Worker code // When Spotify client is minimised, requestAnimationFrame does not call our tick function // setTimeout and setInterval are also throttled at 1 second. // Offload setInterval to a Worker to consistently call tick function. let num = null; // biome-ignore lint/suspicious/noGlobalAssign: onmessage = (event) => { if (event.data === "popup-lyric-request-update") { console.warn("popup-lyric-request-update"); num = setInterval(() => postMessage("popup-lyric-update-ui"), 16.66); } else if (event.data === "popup-lyric-stop-update") { clearInterval(num); postMessage("popup-lyric-update-ui"); num = null; } }; } else { PopupLyrics(); } let CACHE = {}; function PopupLyrics() { const { Player, CosmosAsync, LocalStorage, ContextMenu } = Spicetify; if (!CosmosAsync || !LocalStorage || !ContextMenu) { setTimeout(PopupLyrics, 500); return; } const worker = new Worker("./extensions/popupLyrics.js"); worker.onmessage = (event) => { if (event.data === "popup-lyric-update-ui") { tick(userConfigs); } }; let workerIsRunning = null; document.addEventListener("visibilitychange", (e) => { if (e.target.hidden) { if (!workerIsRunning) { worker.postMessage("popup-lyric-request-update"); workerIsRunning = true; } } else { if (workerIsRunning) { worker.postMessage("popup-lyric-stop-update"); workerIsRunning = false; } } }); const LyricUtils = { normalize(s, emptySymbol = true) { const result = s .replace(/(/g, "(") .replace(/)/g, ")") .replace(/【/g, "[") .replace(/】/g, "]") .replace(/。/g, ". ") .replace(/;/g, "; ") .replace(/:/g, ": ") .replace(/?/g, "? ") .replace(/!/g, "! ") .replace(/、|,/g, ", ") .replace(/‘|’|′|'/g, "'") .replace(/“|”/g, '"') .replace(/〜/g, "~") .replace(/·|・/g, "•"); if (emptySymbol) { result.replace(/-/g, " ").replace(/\//g, " "); } return result.replace(/\s+/g, " ").trim(); }, removeExtraInfo(s) { return ( s .replace(/-\s+(feat|with|prod).*/i, "") .replace(/(\(|\[)(feat|with|prod)\.?\s+.*(\)|\])$/i, "") .replace(/\s-\s.*/, "") .trim() || s ); }, capitalize(s) { return s.replace(/^(\w)/, ($1) => $1.toUpperCase()); }, }; const LyricProviders = { async fetchSpotify(info) { const baseURL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"; const id = info.uri.split(":")[2]; const body = await CosmosAsync.get(`${baseURL + id}?format=json&vocalRemoval=false&market=from_token`); const lyricsData = body.lyrics; if (!lyricsData || lyricsData.syncType !== "LINE_SYNCED") { return { error: "No lyrics" }; } const lines = lyricsData.lines; const lyrics = lines.map((a) => ({ startTime: a.startTimeMs / 1000, text: a.words, })); return { lyrics }; }, async fetchMusixmatch(info) { const baseURL = "https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get?format=json&namespace=lyrics_synched&subtitle_format=mxm&app_id=web-desktop-app-v1.0&"; const durr = info.duration / 1000; const params = { q_album: info.album, q_artist: info.artist, q_artists: info.artist, q_track: info.title, track_spotify_id: info.uri, q_duration: durr, f_subtitle_length: Math.floor(durr), usertoken: userConfigs.services.musixmatch.token, }; const finalURL = baseURL + Object.keys(params) .map((key) => `${key}=${encodeURIComponent(params[key])}`) .join("&"); try { let body = await CosmosAsync.get(finalURL, null, { authority: "apic-desktop.musixmatch.com", cookie: "x-mxm-token-guid=", }); body = body.message.body.macro_calls; if (body["matcher.track.get"].message.header.status_code !== 200) { const head = body["matcher.track.get"].message.header; return { error: `Requested error: ${head.status_code}: ${head.hint} - ${head.mode}`, }; } const meta = body["matcher.track.get"].message.body; const hasSynced = meta.track.has_subtitles; const isRestricted = body["track.lyrics.get"].message.header.status_code === 200 && body["track.lyrics.get"].message.body.lyrics.restricted; const isInstrumental = meta.track.instrumental; if (isRestricted) return { error: "Unfortunately we're not authorized to show these lyrics." }; if (isInstrumental) return { error: "Instrumental" }; if (hasSynced) { const subtitle = body["track.subtitles.get"].message.body.subtitle_list[0].subtitle; const lyrics = JSON.parse(subtitle.subtitle_body).map((line) => ({ text: line.text || "♪", startTime: line.time.total, })); return { lyrics }; } return { error: "No lyrics" }; } catch (err) { return { error: err.message }; } }, async fetchNetease(info) { const searchURL = "https://music.xianqiao.wang/neteaseapiv2/search?limit=10&type=1&keywords="; const lyricURL = "https://music.xianqiao.wang/neteaseapiv2/lyric?id="; const requestHeader = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0", }; const cleanTitle = LyricUtils.removeExtraInfo(LyricUtils.normalize(info.title)); const finalURL = searchURL + encodeURIComponent(`${cleanTitle} ${info.artist}`); const searchResults = await CosmosAsync.get(finalURL, null, requestHeader); const items = searchResults.result.songs; if (!items || !items.length) { return { error: "Cannot find track" }; } const album = LyricUtils.capitalize(info.album); const itemId = items.findIndex((val) => LyricUtils.capitalize(val.album.name) === album || Math.abs(info.duration - val.duration) < 1000); if (itemId === -1) return { error: "Cannot find track" }; const meta = await CosmosAsync.get(lyricURL + items[itemId].id, null, requestHeader); let lyricStr = meta.lrc; if (!lyricStr || !lyricStr.lyric) { return { error: "No lyrics" }; } lyricStr = lyricStr.lyric; const otherInfoKeys = [ "\\s?作?\\s*词|\\s?作?\\s*曲|\\s?编\\s*曲?|\\s?监\\s*制?", ".*编写|.*和音|.*和声|.*合声|.*提琴|.*录|.*工程|.*工作室|.*设计|.*剪辑|.*制作|.*发行|.*出品|.*后期|.*混音|.*缩混", "原唱|翻唱|题字|文案|海报|古筝|二胡|钢琴|吉他|贝斯|笛子|鼓|弦乐", "lrc|publish|vocal|guitar|program|produce|write|mix", ]; const otherInfoRegexp = new RegExp(`^(${otherInfoKeys.join("|")}).*(:|:)`, "i"); const lines = lyricStr.split(/\r?\n/).map((line) => line.trim()); let noLyrics = false; const lyrics = lines .flatMap((line) => { // ["[ar:Beyond]"] // ["[03:10]"] // ["[03:10]", "永远高唱我歌"] // ["永远高唱我歌"] // ["[03:10]", "[03:10]", "永远高唱我歌"] const matchResult = line.match(/(\[.*?\])|([^[\]]+)/g) || [line]; if (!matchResult.length || matchResult.length === 1) { return; } const textIndex = matchResult.findIndex((slice) => !slice.endsWith("]")); let text = ""; if (textIndex > -1) { text = matchResult.splice(textIndex, 1)[0]; text = LyricUtils.capitalize(LyricUtils.normalize(text, false)); } if (text === "纯音乐, 请欣赏") noLyrics = true; return matchResult.map((slice) => { const result = {}; const matchResult = slice.match(/[^[\]]+/g); const [key, value] = matchResult[0].split(":") || []; const [min, sec] = [Number.parseFloat(key), Number.parseFloat(value)]; if (!Number.isNaN(min) && !Number.isNaN(sec) && !otherInfoRegexp.test(text)) { result.startTime = min * 60 + sec; result.text = text || "♪"; return result; } return; }); }) .sort((a, b) => { if (a.startTime === null) { return 0; } if (b.startTime === null) { return 1; } return a.startTime - b.startTime; }) .filter(Boolean); if (noLyrics) { return { error: "No lyrics" }; } if (!lyrics.length) { return { error: "No synced lyrics" }; } return { lyrics }; }, async fetchLrclib(info) { const baseURL = "https://lrclib.net/api/get"; const durr = info.duration / 1000; const params = { track_name: info.title, artist_name: info.artist, album_name: info.album, duration: durr, }; const finalURL = `${baseURL}?${Object.keys(params) .map((key) => `${key}=${encodeURIComponent(params[key])}`) .join("&")}`; const body = await fetch(finalURL, { headers: { "x-user-agent": `spicetify v${Spicetify.Config.version} (https://github.com/spicetify/cli)`, }, }); if (body.status !== 200) { return { error: "Request error: Track wasn't found" }; } const meta = await body.json(); if (meta?.instrumental) { return { error: "Instrumental" }; } if (!meta?.syncedLyrics) { return { error: "No synced lyrics" }; } // Preprocess lyrics by removing [tags] and empty lines const lines = meta?.syncedLyrics .replaceAll(/\[[a-zA-Z]+:.+\]/g, "") .trim() .split("\n"); const syncedTimestamp = /\[([0-9:.]+)\]/; const isSynced = lines[0].match(syncedTimestamp); const lyrics = lines.map((line) => { const time = line.match(syncedTimestamp)?.[1]; const lyricContent = line.replace(syncedTimestamp, "").trim(); const lyric = lyricContent.replaceAll(/<([0-9:.]+)>/g, "").trim(); const [min, sec] = time.replace(/\[\]<>/, "").split(":"); if (line.trim() !== "" && isSynced && time) { return { text: lyric || "♪", startTime: Number(min) * 60 + Number(sec) }; } return; }); return { lyrics }; }, }; const userConfigs = { smooth: boolLocalStorage("popup-lyrics:smooth"), centerAlign: boolLocalStorage("popup-lyrics:center-align"), showCover: boolLocalStorage("popup-lyrics:show-cover"), fontSize: Number(LocalStorage.get("popup-lyrics:font-size")), blurSize: Number(LocalStorage.get("popup-lyrics:blur-size")), fontFamily: LocalStorage.get("popup-lyrics:font-family") || "spotify-circular", ratio: LocalStorage.get("popup-lyrics:ratio") || "11", delay: Number(LocalStorage.get("popup-lyrics:delay")), services: { netease: { on: boolLocalStorage("popup-lyrics:services:netease:on"), call: LyricProviders.fetchNetease, desc: "Crowdsourced lyrics provider ran by Chinese developers and users.", }, musixmatch: { on: boolLocalStorage("popup-lyrics:services:musixmatch:on"), call: LyricProviders.fetchMusixmatch, desc: "Fully compatible with Spotify. Requires a token that can be retrieved from the official Musixmatch app. If you have problems with retrieving lyrics, try refreshing the token by clicking Refresh Token button.", token: LocalStorage.get("popup-lyrics:services:musixmatch:token") || "2005218b74f939209bda92cb633c7380612e14cb7fe92dcd6a780f", }, spotify: { on: boolLocalStorage("popup-lyrics:services:spotify:on"), call: LyricProviders.fetchSpotify, desc: "Lyrics sourced from official Spotify API.", }, lrclib: { on: boolLocalStorage("popup-lyrics:services:lrclib:on"), call: LyricProviders.fetchLrclib, desc: "Lyrics sourced from lrclib.net. Supports both synced and unsynced lyrics. LRCLIB is a free and open-source lyrics provider.", }, }, servicesOrder: [], }; userConfigs.fontSize = userConfigs.fontSize ? Number(userConfigs.fontSize) : 46; try { const rawServicesOrder = LocalStorage.get("popup-lyrics:services-order"); userConfigs.servicesOrder = JSON.parse(rawServicesOrder); if (!Array.isArray(userConfigs.servicesOrder)) throw ""; userConfigs.servicesOrder = userConfigs.servicesOrder.filter((s) => userConfigs.services[s]); // Remove obsoleted services const allServices = Object.keys(userConfigs.services); if (userConfigs.servicesOrder.length !== allServices.length) { for (const s of allServices) { if (!userConfigs.servicesOrder.includes(s)) { userConfigs.servicesOrder.push(s); } } LocalStorage.set("popup-lyrics:services-order", JSON.stringify(userConfigs.servicesOrder)); } } catch { userConfigs.servicesOrder = Object.keys(userConfigs.services); LocalStorage.set("popup-lyrics:services-order", JSON.stringify(userConfigs.servicesOrder)); } const lyricVideo = document.createElement("video"); lyricVideo.muted = true; lyricVideo.width = 600; switch (userConfigs.ratio) { case "43": lyricVideo.height = Math.round((lyricVideo.width * 3) / 4); break; case "169": lyricVideo.height = Math.round((lyricVideo.width * 9) / 16); break; default: lyricVideo.height = lyricVideo.width; break; } let lyricVideoIsOpen = false; lyricVideo.onenterpictureinpicture = () => { lyricVideo.play(); lyricVideoIsOpen = true; tick(userConfigs); updateTrack(); }; lyricVideo.onleavepictureinpicture = () => { lyricVideoIsOpen = false; }; const lyricCanvas = document.createElement("canvas"); lyricCanvas.width = lyricVideo.width; lyricCanvas.height = lyricVideo.height; const lyricCtx = lyricCanvas.getContext("2d"); lyricVideo.srcObject = lyricCanvas.captureStream(); lyricCtx.fillRect(0, 0, 1, 1); lyricVideo.play(); const button = new Spicetify.Topbar.Button("Popup Lyrics", "lyrics", () => { if (!lyricVideoIsOpen) { lyricVideo.requestPictureInPicture(); } else { document.exitPictureInPicture(); } }); button.element.oncontextmenu = openConfig; const coverCanvas = document.createElement("canvas"); coverCanvas.width = lyricVideo.width; coverCanvas.height = lyricVideo.width; const coverCtx = coverCanvas.getContext("2d"); const largeImage = new Image(); largeImage.onload = () => { coverCtx.drawImage(largeImage, 0, 0, coverCtx.canvas.width, coverCtx.canvas.width); }; userConfigs.backgroundImage = coverCanvas; let sharedData = {}; Player.addEventListener("songchange", () => { updateTrack(); }); async function updateTrack(refresh = false) { if (!lyricVideoIsOpen) { return; } const meta = Player.data.item.metadata; if (!Spicetify.URI.isTrack(Player.data.item.uri) && !Spicetify.URI.isLocalTrack(Player.data.item.uri)) { return; } largeImage.src = meta.image_url; const info = { duration: Number(meta.duration), album: meta.album_title, artist: meta.artist_name, title: meta.title, uri: Player.data.item.uri, }; if (CACHE?.[info.uri]?.lyrics?.length && !refresh) { sharedData = CACHE[info.uri]; } else { for (const name of userConfigs.servicesOrder) { const service = userConfigs.services[name]; if (!service.on) continue; sharedData = { lyrics: [] }; try { const data = await service.call(info); sharedData = data; CACHE[info.uri] = sharedData; if (!sharedData.error) { return; } } catch (err) { sharedData = { error: "No lyrics" }; } } } } // simple word segmentation rules function getWords(str) { const result = []; const words = str.split(/(\p{sc=Han}|\p{sc=Katakana}|\p{sc=Hiragana}|\p{sc=Hang}|\p{gc=Punctuation})|\s+/gu); let tempWord = ""; for (let word of words) { word ??= " "; if (word) { if (tempWord && /(“|')$/.test(tempWord) && word !== " ") { // End of line not allowed tempWord += word; } else if (/(,|\.|\?|:|;|'|,|。|?|:|;|”)/.test(word) && tempWord !== " ") { // Start of line not allowed tempWord += word; } else { if (tempWord) result.push(tempWord); tempWord = word; } } } if (tempWord) result.push(tempWord); return result; } function drawParagraph(ctx, str, options) { let actualWidth = 0; const maxWidth = ctx.canvas.width - options.left - options.right; const words = getWords(str); const lines = []; const measures = []; let tempLine = ""; let textMeasures = ctx.measureText(""); for (let i = 0; i < words.length; i++) { const word = words[i]; const line = tempLine + word; const mea = ctx.measureText(line); const isSpace = /\s/.test(word); if (mea.width > maxWidth && tempLine && !isSpace) { actualWidth = Math.max(actualWidth, textMeasures.width); lines.push(tempLine); measures.push(textMeasures); tempLine = word; } else { tempLine = line; if (!isSpace) { textMeasures = mea; } } } if (tempLine !== "") { actualWidth = Math.max(actualWidth, textMeasures.width); lines.push(tempLine); measures.push(ctx.measureText(tempLine)); } const ascent = measures.length ? measures[0].actualBoundingBoxAscent : 0; const body = measures.length ? options.lineHeight * (measures.length - 1) : 0; const descent = measures.length ? measures[measures.length - 1].actualBoundingBoxDescent : 0; const actualHeight = ascent + body + descent; let startX = 0; let startY = 0; let translateX = 0; let translateY = 0; if (options.hCenter) { startX = (ctx.canvas.width - actualWidth) / 2; } else { startX = options.left + translateX; } if (options.vCenter) { startY = (ctx.canvas.height - actualHeight) / 2 + ascent; } else if (options.top) { startY = options.top + ascent; } else if (options.bottom) { startY = options.bottom - descent - body; } if (typeof options.translateX === "function") { translateX = options.translateX(actualWidth); } if (typeof options.translateX === "number") { translateX = options.translateX; } if (typeof options.translateY === "function") { translateY = options.translateY(actualHeight); } if (typeof options.translateY === "number") { translateY = options.translateY; } if (!options.measure) { lines.forEach((str, index) => { const x = options.hCenter ? (ctx.canvas.width - measures[index].width) / 2 : startX; ctx.fillText(str, x, startY + index * options.lineHeight + translateY); }); } return { width: actualWidth, height: actualHeight, left: startX + translateX, right: ctx.canvas.width - options.left - actualWidth + translateX, top: startY - ascent + translateY, bottom: startY + body + descent + translateY, }; } function drawBackground(ctx, image) { if (userConfigs.showCover) { const { width, height } = ctx.canvas; ctx.imageSmoothingEnabled = false; ctx.save(); const blurSize = Number(userConfigs.blurSize); ctx.filter = `blur(${blurSize}px)`; ctx.drawImage(image, -blurSize * 2, -blurSize * 2 - (width - height) / 2, width + 4 * blurSize, width + 4 * blurSize); ctx.restore(); ctx.fillStyle = "#000000b0"; } else { ctx.save(); ctx.fillStyle = "#000000"; } ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.restore(); } function drawText(ctx, text, color = "white") { drawBackground(ctx, userConfigs.backgroundImage); const fontSize = userConfigs.fontSize; ctx.fillStyle = color; ctx.font = `bold ${fontSize}px ${userConfigs.fontFamily}, sans-serif`; drawParagraph(ctx, text, { vCenter: true, hCenter: true, left: 0, right: 0, lineHeight: fontSize, }); ctx.restore(); } let offscreenCanvas; let offscreenCtx; let gradient1; let gradient2; function initOffscreenCtx(ctx) { if (!offscreenCtx) { offscreenCanvas = document.createElement("canvas"); offscreenCtx = offscreenCanvas.getContext("2d"); gradient1 = offscreenCtx.createLinearGradient(0, 0, 0, ctx.canvas.height); gradient1.addColorStop(0.08, "transparent"); gradient1.addColorStop(0.15, "white"); gradient1.addColorStop(0.85, "white"); gradient1.addColorStop(0.92, "transparent"); gradient2 = offscreenCtx.createLinearGradient(0, 0, 0, ctx.canvas.height); gradient2.addColorStop(0.0, "white"); gradient2.addColorStop(0.7, "white"); gradient2.addColorStop(0.925, "transparent"); } offscreenCtx.canvas.width = ctx.canvas.width; offscreenCtx.canvas.height = ctx.canvas.height; return { offscreenCtx, gradient1, gradient2, }; } // Avoid drawing again when the same // Do not operate canvas again in other functions let renderState; function isEqualState(state1, state2) { if (!state1 || !state2) return false; return Object.keys(state1).reduce((p, c) => { return p && state1[c] === state2[c]; }, true); } function renderLyrics(ctx, lyrics, currentTime) { const focusLineFontSize = userConfigs.fontSize; const focusLineHeight = focusLineFontSize * 1.2; const focusLineMargin = focusLineFontSize * 1; const otherLineFontSize = focusLineFontSize * 1; const otherLineHeight = otherLineFontSize * 1.2; const otherLineMargin = otherLineFontSize * 1; const otherLineOpacity = 0.35; const marginWidth = ctx.canvas.width * 0.075; const animateDuration = userConfigs.smooth ? 0.3 : 0; const hCenter = userConfigs.centerAlign; const fontFamily = `${userConfigs.fontFamily}, sans-serif`; let currentIndex = -1; let progress = 1; lyrics.forEach(({ startTime }, index) => { if (startTime && currentTime > startTime - animateDuration) { currentIndex = index; if (currentTime < startTime) { progress = (currentTime - startTime + animateDuration) / animateDuration; } } }); if (currentIndex === -1) { drawText(ctx, ""); return; } const nextState = { ...userConfigs, currentIndex, lyrics, progress, }; if (isEqualState(nextState, renderState)) return; renderState = nextState; drawBackground(ctx, userConfigs.backgroundImage); const { offscreenCtx, gradient1 } = initOffscreenCtx(ctx); offscreenCtx.save(); // focus line const fFontSize = otherLineFontSize + progress * (focusLineFontSize - otherLineFontSize); const fLineHeight = otherLineHeight + progress * (focusLineHeight - otherLineHeight); const fLineOpacity = otherLineOpacity + progress * (1 - otherLineOpacity); const otherRight = ctx.canvas.width - marginWidth - (otherLineFontSize / focusLineFontSize) * (ctx.canvas.width - 2 * marginWidth); const progressRight = marginWidth + (1 - progress) * (otherRight - marginWidth); offscreenCtx.fillStyle = `rgba(255, 255, 255, ${fLineOpacity})`; offscreenCtx.font = `bold ${fFontSize}px ${fontFamily}`; const prevLineFocusHeight = drawParagraph(offscreenCtx, lyrics[currentIndex - 1] ? lyrics[currentIndex - 1].text : "", { vCenter: true, hCenter, left: marginWidth, right: marginWidth, lineHeight: focusLineFontSize, measure: true, }).height; const pos = drawParagraph(offscreenCtx, lyrics[currentIndex].text, { vCenter: true, hCenter, left: marginWidth, right: progressRight, lineHeight: fLineHeight, translateY: (selfHeight) => ((prevLineFocusHeight + selfHeight) / 2 + focusLineMargin) * (1 - progress), }); // offscreenCtx.strokeRect(pos.left, pos.top, pos.width, pos.height); // prev line let lastBeforePos = pos; for (let i = 0; i < currentIndex; i++) { if (i === 0) { const prevProgressLineFontSize = otherLineFontSize + (1 - progress) * (focusLineFontSize - otherLineFontSize); const prevProgressLineOpacity = otherLineOpacity + (1 - progress) * (1 - otherLineOpacity); offscreenCtx.fillStyle = `rgba(255, 255, 255, ${prevProgressLineOpacity})`; offscreenCtx.font = `bold ${prevProgressLineFontSize}px ${fontFamily}`; } else { offscreenCtx.fillStyle = `rgba(255, 255, 255, ${otherLineOpacity})`; offscreenCtx.font = `bold ${otherLineFontSize}px ${fontFamily}`; } lastBeforePos = drawParagraph(offscreenCtx, lyrics[currentIndex - 1 - i].text, { hCenter, bottom: i === 0 ? lastBeforePos.top - focusLineMargin : lastBeforePos.top - otherLineMargin, left: marginWidth, right: i === 0 ? marginWidth + progress * (otherRight - marginWidth) : otherRight, lineHeight: i === 0 ? otherLineHeight + (1 - progress) * (focusLineHeight - otherLineHeight) : otherLineHeight, }); if (lastBeforePos.top < 0) break; } // next line offscreenCtx.fillStyle = `rgba(255, 255, 255, ${otherLineOpacity})`; offscreenCtx.font = `bold ${otherLineFontSize}px ${fontFamily}`; let lastAfterPos = pos; for (let i = currentIndex + 1; i < lyrics.length; i++) { lastAfterPos = drawParagraph(offscreenCtx, lyrics[i].text, { hCenter, top: i === currentIndex + 1 ? lastAfterPos.bottom + focusLineMargin : lastAfterPos.bottom + otherLineMargin, left: marginWidth, right: otherRight, lineHeight: otherLineHeight, }); if (lastAfterPos.bottom > ctx.canvas.height) break; } offscreenCtx.globalCompositeOperation = "source-in"; offscreenCtx.fillStyle = gradient1; offscreenCtx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); offscreenCtx.restore(); ctx.drawImage(offscreenCtx.canvas, 0, 0); ctx.restore(); } let timeout = null; async function tick(options) { if (!lyricVideoIsOpen) { return; } if (timeout) clearTimeout(timeout); const audio = { currentTime: (Player.getProgress() - Number(options.delay)) / 1000, duration: Player.getDuration() / 1000, }; const { error, lyrics } = sharedData; if (error) { if (error === "Instrumental") { drawText(lyricCtx, error); } else { drawText(lyricCtx, error, "red"); } } else if (!lyrics) { drawText(lyricCtx, "No lyrics"); } else if (audio.duration && lyrics.length) { renderLyrics(lyricCtx, lyrics, audio.currentTime); } else if (!audio.duration || lyrics.length === 0) { drawText(lyricCtx, audio.currentSrc ? "Loading" : "Waiting"); } if (!lyrics?.length) { timeout = setTimeout(tick, 1000, options); return; } if (!document.hidden) { requestAnimationFrame(() => tick(options)); } } function boolLocalStorage(name, defaultVal = true) { const value = LocalStorage.get(name); return value ? value === "true" : defaultVal; } let configContainer; function openConfig(event) { event.preventDefault(); // Reset on reopen if (configContainer) { resetTokenButton(configContainer); } else { configContainer = document.createElement("div"); configContainer.id = "popup-config-container"; const style = document.createElement("style"); style.innerHTML = ` .setting-row { display: flex; justify-content: space-between; align-items: center; } .setting-row::after { content: ""; display: table; clear: both; } .setting-row .col { display: flex; padding: 10px 0; align-items: center; } .setting-row .col.description { padding-right: 15px; cursor: default; width: 50%; } .setting-row .col.action { justify-content: flex-end; width: 50%; } .popup-config-col-margin { margin-top: 10px; } button.switch { align-items: center; border: 0px; border-radius: 50%; background-color: rgba(var(--spice-rgb-shadow), .7); color: var(--spice-text); cursor: pointer; display: flex; margin-inline-start: 12px; padding: 8px; } button.switch.disabled, button.switch[disabled] { color: rgba(var(--spice-rgb-text), .3); } button.switch.small { width: 22px; height: 22px; padding: 6px; } button.btn { font-weight: 700; display: block; background-color: rgba(var(--spice-rgb-shadow), .7); border-radius: 500px; transition-duration: 33ms; transition-property: background-color, border-color, color, box-shadow, filter, transform; padding-inline: 15px; border: 1px solid #727272; color: var(--spice-text); min-block-size: 32px; cursor: pointer; } button.btn:hover { transform: scale(1.04); border-color: var(--spice-text); } button.btn:disabled { opacity: 0.5; cursor: not-allowed; } #popup-config-container select { color: var(--spice-text); background: rgba(var(--spice-rgb-shadow), .7); border: 0; height: 32px; } #popup-config-container input { width: 100%; padding: 0 5px; height: 32px; border: 0; } #popup-lyrics-delay-input { background-color: rgba(var(--spice-rgb-shadow), .7); color: var(--spice-text); } `; const optionHeader = document.createElement("h2"); optionHeader.innerText = "Options"; const smooth = createSlider("Smooth scrolling", userConfigs.smooth, (state) => { userConfigs.smooth = state; LocalStorage.set("popup-lyrics:smooth", String(state)); }); const center = createSlider("Center align", userConfigs.centerAlign, (state) => { userConfigs.centerAlign = state; LocalStorage.set("popup-lyrics:center-align", String(state)); }); const cover = createSlider("Show cover", userConfigs.showCover, (state) => { userConfigs.showCover = state; LocalStorage.set("popup-lyrics:show-cover", String(state)); }); const ratio = createOptions("Aspect ratio", { 11: "1:1", 43: "4:3", 169: "16:9" }, userConfigs.ratio, (state) => { userConfigs.ratio = state; LocalStorage.set("popup-lyrics:ratio", state); let value = lyricVideo.width; switch (userConfigs.ratio) { case "11": value = lyricVideo.width; break; case "43": value = Math.round((lyricVideo.width * 3) / 4); break; case "169": value = Math.round((lyricVideo.width * 9) / 16); break; } lyricVideo.height = lyricCanvas.height = value; offscreenCtx = null; }); const fontSize = createOptions( "Font size", { 30: "30px", 34: "34px", 38: "38px", 42: "42px", 46: "46px", 50: "50px", 54: "54px", 58: "58px", }, String(userConfigs.fontSize), (state) => { userConfigs.fontSize = Number(state); LocalStorage.set("popup-lyrics:font-size", state); } ); const blurSize = createOptions( "Blur size", { 2: "2px", 5: "5px", 10: "10px", 15: "15px", }, String(userConfigs.blurSize), (state) => { userConfigs.blurSize = Number(state); LocalStorage.set("popup-lyrics:blur-size", state); } ); const delay = createOptionsInput("Delay", String(userConfigs.delay), (state) => { userConfigs.delay = Number(state); LocalStorage.set("popup-lyrics:delay", state); }); const clearCache = descriptiveElement( createButton("Clear Memory Cache", "Clear Memory Cache", () => { CACHE = {}; updateTrack(); }), "Loaded lyrics are cached in memory for faster reloading. Press this button to clear the cached lyrics from memory without restarting Spotify." ); const serviceHeader = document.createElement("h2"); serviceHeader.innerText = "Services"; const serviceContainer = document.createElement("div"); function stackServiceElements() { userConfigs.servicesOrder.forEach((name, index) => { const el = userConfigs.services[name].element; const [up, down] = el.querySelectorAll("button"); if (index === 0) { up.disabled = true; down.disabled = false; } else if (index === userConfigs.servicesOrder.length - 1) { up.disabled = false; down.disabled = true; } else { up.disabled = false; down.disabled = false; } serviceContainer.append(el); }); } function switchCallback(el, state) { const id = el.dataset.id; userConfigs.services[id].on = state; LocalStorage.set(`popup-lyrics:services:${id}:on`, state); updateTrack(true); } function posCallback(el, dir) { const id = el.dataset.id; const curPos = userConfigs.servicesOrder.findIndex((val) => val === id); const newPos = curPos + dir; const temp = userConfigs.servicesOrder[newPos]; userConfigs.servicesOrder[newPos] = userConfigs.servicesOrder[curPos]; userConfigs.servicesOrder[curPos] = temp; LocalStorage.set("popup-lyrics:services-order", JSON.stringify(userConfigs.servicesOrder)); stackServiceElements(); updateTrack(true); } for (const name of userConfigs.servicesOrder) { userConfigs.services[name].element = createServiceOption(name, userConfigs.services[name], switchCallback, posCallback); } stackServiceElements(); configContainer.append( style, optionHeader, smooth, center, cover, blurSize, fontSize, ratio, delay, clearCache, serviceHeader, serviceContainer ); } Spicetify.PopupModal.display({ title: "Popup Lyrics", content: configContainer, }); } function createSlider(name, defaultVal, callback) { const container = document.createElement("div"); container.innerHTML = `
    `; const slider = container.querySelector("button"); slider.classList.toggle("disabled", !defaultVal); slider.onclick = () => { const state = slider.classList.contains("disabled"); slider.classList.toggle("disabled"); callback(state); }; return container; } function createOptions(name, options, defaultValue, callback) { const container = document.createElement("div"); container.innerHTML = `
    `; const select = container.querySelector("select"); select.value = defaultValue; select.onchange = (e) => { callback(e.target.value); }; return container; } function createOptionsInput(name, defaultValue, callback) { const container = document.createElement("div"); container.innerHTML = `
    `; const input = container.querySelector("#popup-lyrics-delay-input"); input.value = defaultValue; input.onchange = (e) => { callback(e.target.value); }; return container; } // if name is null, the element can be used without a description. function createButton(name, defaultValue, callback) { let container; if (name) { container = document.createElement("div"); container.innerHTML = `
    `; const button = container.querySelector("#popup-lyrics-clickbutton"); button.onclick = () => { callback(); }; } else { container = document.createElement("button"); container.innerHTML = defaultValue; container.className = "btn "; container.onclick = () => { callback(); }; } return container; } // if name is null, the element can be used without a description. function createTextfield(name, defaultValue, placeholder, callback) { let container; if (name) { container = document.createElement("div"); container.className = "setting-column"; container.innerHTML = ` `; const textfield = container.querySelector("#popup-lyrics-textfield"); textfield.onchange = () => { callback(); }; } else { container = document.createElement("input"); container.placeholder = placeholder; container.value = defaultValue; container.onchange = (e) => { callback(e.target.value); }; } return container; } function descriptiveElement(element, description) { const desc = document.createElement("span"); desc.innerHTML = description; element.append(desc); return element; } function resetTokenButton(container) { const button = container.querySelector("#popup-lyrics-refresh-token"); if (button) { button.innerHTML = "Refresh token"; button.disabled = false; } } function musixmatchTokenElements(defaultVal, id) { const button = createButton(null, "Refresh token", clickRefresh); button.className += "popup-config-col-margin"; button.id = "popup-lyrics-refresh-token"; const textfield = createTextfield(null, defaultVal.token, `Place your ${id} token here`, changeTokenfield); textfield.className += "popup-config-col-margin"; function clickRefresh() { button.innerHTML = "Refreshing token..."; button.disabled = true; Spicetify.CosmosAsync.get("https://apic-desktop.musixmatch.com/ws/1.1/token.get?app_id=web-desktop-app-v1.0", null, { authority: "apic-desktop.musixmatch.com", }) .then(({ message: response }) => { if (response.header.status_code === 200 && response.body.user_token) { button.innerHTML = "Token refreshed"; textfield.value = response.body.user_token; textfield.dispatchEvent(new Event("change")); } else if (response.header.status_code === 401) { button.innerHTML = "Too many attempts"; } else { button.innerHTML = "Failed to refresh token"; console.error("Failed to refresh token", response); } }) .catch((error) => { button.innerHTML = "Failed to refresh token"; console.error("Failed to refresh token", error); }); } function changeTokenfield(value) { userConfigs.services.musixmatch.token = value; LocalStorage.set("popup-lyrics:services:musixmatch:token", value); updateTrack(true); } const container = document.createElement("div"); container.append(button); container.append(textfield); return container; } function createServiceOption(id, defaultVal, switchCallback, posCallback) { const name = id.replace(/^./, (c) => c.toUpperCase()); const container = document.createElement("div"); container.dataset.id = id; container.innerHTML = `

    ${name}

    ${defaultVal.desc}`; if (id === "musixmatch") { container.append(musixmatchTokenElements(defaultVal)); } const [up, down, slider] = container.querySelectorAll("button"); slider.classList.toggle("disabled", !defaultVal.on); slider.onclick = () => { const state = slider.classList.contains("disabled"); slider.classList.toggle("disabled"); switchCallback(container, state); }; up.onclick = () => posCallback(container, -1); down.onclick = () => posCallback(container, 1); return container; } } ================================================ FILE: Extensions/shuffle+.js ================================================ // NAME: Shuffle+ // AUTHORS: khanhas, Tetrax-10 // DESCRIPTION: True shuffle with no bias. /// (async function shufflePlus() { if (!(Spicetify.CosmosAsync && Spicetify.Platform)) { setTimeout(shufflePlus, 300); return; } const { React } = Spicetify; const { useState } = React; let playbarButton = null; function getConfig() { try { const parsed = JSON.parse(Spicetify.LocalStorage.get("shufflePlus:settings")); if (parsed && typeof parsed === "object") { return parsed; } throw ""; } catch { Spicetify.LocalStorage.set("shufflePlus:settings", "{}"); return { artistMode: "all", artistNameMust: false, enableQueueButton: false, }; } } const CONFIG = getConfig(); saveConfig(); function saveConfig() { Spicetify.LocalStorage.set("shufflePlus:settings", JSON.stringify(CONFIG)); } function settingsPage() { const style = React.createElement( "style", null, `.popup-row::after { content: ""; display: table; clear: both; } .popup-row .col { display: flex; padding: 10px 0; align-items: center; } .popup-row .col.description { float: left; padding-right: 15px; } .popup-row .col.action { float: right; text-align: right; } .popup-row .div-title { color: var(--spice-text); } .popup-row .divider { height: 2px; border-width: 0; background-color: var(--spice-button-disabled); } button.checkbox { align-items: center; border: 0px; border-radius: 50%; background-color: rgba(var(--spice-rgb-shadow), 0.7); color: var(--spice-text); cursor: pointer; display: flex; margin-inline-start: 12px; padding: 8px; } button.checkbox.disabled { color: rgba(var(--spice-rgb-text), 0.3); } select { color: var(--spice-text); background: rgba(var(--spice-rgb-shadow), 0.7); border: 0; height: 32px; } ::-webkit-scrollbar { width: 8px; }` ); function DisplayIcon({ icon, size }) { return React.createElement("svg", { width: size, height: size, viewBox: "0 0 16 16", fill: "currentColor", dangerouslySetInnerHTML: { __html: icon, }, }); } function checkBoxItem({ name, field, onclickFun = () => {} }) { const [value, setValue] = useState(CONFIG[field]); return React.createElement( "div", { className: "popup-row" }, React.createElement("label", { className: "col description" }, name), React.createElement( "div", { className: "col action" }, React.createElement( "button", { className: `checkbox${value ? "" : " disabled"}`, onClick: () => { CONFIG[field] = !value; setValue(!value); saveConfig(); onclickFun(); }, }, React.createElement(DisplayIcon, { icon: Spicetify.SVGIcons.check, size: 16, }) ) ) ); } function dropDownItem({ name, field, options, onclickFun = () => {} }) { const [value, setValue] = useState(CONFIG[field]); return React.createElement( "div", { className: "popup-row" }, React.createElement("label", { className: "col description" }, name), React.createElement( "div", { className: "col action" }, React.createElement( "select", { value, onChange: (e) => { setValue(e.target.value); CONFIG[field] = e.target.value; saveConfig(); onclickFun(); }, }, Object.keys(options).map((item) => React.createElement( "option", { value: item, }, options[item] ) ) ) ) ); } const settingsDOMContent = React.createElement( "div", null, style, React.createElement("div", { className: "popup-row" }, React.createElement("h3", { className: "div-title" }, "Artist Shuffle")), React.createElement("div", { className: "popup-row" }, React.createElement("hr", { className: "divider" }, null)), React.createElement(dropDownItem, { name: "Shuffle mode Artist Page", field: "artistMode", options: { all: "All", album: "Albums", single: "Singles & EP", likedSongArtist: "Artist's Liked Songs", topTen: "Top 10 Songs", }, }), React.createElement(checkBoxItem, { name: "Chosen artist must be included", field: "artistNameMust", }), React.createElement(checkBoxItem, { name: "Enable Shuffle+ Queue Tracks button in Playbar", field: "enableQueueButton", onclickFun: () => renderQueuePlaybarButton(), }) ); Spicetify.PopupModal.display({ title: "Shuffle+", content: settingsDOMContent, isLarge: true, }); } new Spicetify.Menu.Item("Shuffle+", false, settingsPage, "shuffle").register(); const { Type } = Spicetify.URI; function shouldAddShufflePlus(uri) { if (uri.length === 1) { const uriObj = Spicetify.URI.fromString(uri[0]); switch (uriObj.type) { case Type.PLAYLIST: case Type.PLAYLIST_V2: case Type.ALBUM: case Type.ARTIST: case Type.COLLECTION: case Type.FOLDER: case Type.SHOW: return true; } return false; } return true; } function shouldAddShufflePlusLiked(uri) { const uriObj = Spicetify.URI.fromString(uri[0]); if (Spicetify.Platform.History.location.pathname === "/collection/tracks") { switch (uriObj.type) { case Type.TRACK: return true; } } return false; } function shouldAddShufflePlusLocal(uri) { const uriObj = Spicetify.URI.fromString(uri[0]); if (Spicetify.Platform.History.location.pathname === "/collection/local-files") { switch (uriObj.type) { case Type.TRACK: case Type.LOCAL_TRACK: return true; } } return false; } new Spicetify.ContextMenu.Item( "Play with Shuffle+", async (uri) => { if (uri.length === 1) { await fetchAndPlay(uri[0]); return; } await fetchAndPlay(uri); }, shouldAddShufflePlus, "shuffle" ).register(); new Spicetify.ContextMenu.Item( "Shuffle+ Liked Songs", async (uri) => { await fetchAndPlay(uri[0]); }, shouldAddShufflePlusLiked, "heart-active" ).register(); new Spicetify.ContextMenu.Item( "Shuffle+ Local Files", async (uri) => { await fetchAndPlay(uri[0]); }, shouldAddShufflePlusLocal, "playlist-folder" ).register(); renderQueuePlaybarButton(); function renderQueuePlaybarButton() { if (!playbarButton) { playbarButton = new Spicetify.Playbar.Button( "Shuffle+ Queue Tracks", "enhance", async () => { await fetchAndPlay("queue"); }, false, false ); } if (CONFIG.enableQueueButton) playbarButton.register(); else playbarButton.deregister(); } async function fetchPlaylistTracks(uri) { const res = await Spicetify.Platform.PlaylistAPI.getContents(`spotify:playlist:${uri}`, { limit: 9999999, }); return res.items.filter((track) => track.isPlayable).map((track) => track.uri); } function searchFolder(rows, uri) { for (const r of rows) { if (r.type !== "folder" || !r.items) continue; if (r.uri === uri) return r; const found = searchFolder(r.items, uri); if (found) return found; } } async function fetchFolderTracks(uri) { const res = await Spicetify.Platform.RootlistAPI.getContents(); const requestFolder = searchFolder(res.items, uri); if (!requestFolder) throw "Cannot find folder"; const requestPlaylists = []; async function fetchNested(folder) { if (!folder.items) return; for (const i of folder.items) { if (i.type === "playlist") { const uriObj = Spicetify.URI.fromString(i.uri); const uri = uriObj._base62Id ?? uriObj.id; requestPlaylists.push(await fetchPlaylistTracks(uri)); } else if (i.type === "folder") await fetchNested(i); } } await fetchNested(requestFolder); return requestPlaylists.flat(); } async function fetchAlbumTracks(uri, includeMetadata = false) { const { queryAlbumTracks } = Spicetify.GraphQL.Definitions; const { data, errors } = await Spicetify.GraphQL.Request(queryAlbumTracks, { uri, offset: 0, limit: 100, }); if (errors) throw errors[0].message; if (data.albumUnion.playability.playable === false) throw "Album is not playable"; return (data.albumUnion?.tracksV2 ?? data.albumUnion?.tracks ?? []).items .filter(({ track }) => track.playability.playable) .map(({ track }) => (includeMetadata ? track : track.uri)); } const artistFetchTypeCount = { album: 0, single: 0 }; async function scanForTracksFromAlbums(res, artistName, type) { const allTracks = []; for (const album of res) { let albumRes; try { albumRes = await fetchAlbumTracks(album.uri, true); } catch (error) { console.error(album, error); continue; } artistFetchTypeCount[type]++; Spicetify.showNotification(`${artistFetchTypeCount[type]} / ${res.length} ${type}s`); for (const track of albumRes) { if (!CONFIG.artistNameMust || track.artists.items.some((artist) => artist.profile.name === artistName)) allTracks.push(track.uri); } } return allTracks; } async function fetchArtistTracks(uri) { // Definitions from older Spotify version const queryArtistOverview = { name: "queryArtistOverview", operation: "query", sha256Hash: "35648a112beb1794e39ab931365f6ae4a8d45e65396d641eeda94e4003d41497", value: null, }; const queryArtistDiscographyAll = { name: "queryArtistDiscographyAll", operation: "query", sha256Hash: "9380995a9d4663cbcb5113fef3c6aabf70ae6d407ba61793fd01e2a1dd6929b0", value: null, }; const discography = await Spicetify.GraphQL.Request(queryArtistDiscographyAll, { uri, offset: 0, // Limit 100 since GraphQL has resource limit limit: 100, }); if (discography.errors) throw discography.errors[0].message; const overview = await Spicetify.GraphQL.Request(queryArtistOverview, { uri, locale: Spicetify.Locale.getLocale(), includePrerelease: false, }); if (overview.errors) throw overview.errors[0].message; const artistName = overview.data.artistUnion.profile.name; const releases = discography.data.artistUnion.discography.all.items.flatMap(({ releases }) => releases.items); const artistAlbums = releases.filter((album) => album.type === "ALBUM"); const artistSingles = releases.filter((album) => album.type === "SINGLE" || album.type === "EP"); if (artistAlbums.length === 0 && artistSingles.length === 0) throw "Artist has no releases"; const allArtistAlbumsTracks = CONFIG.artistMode !== "single" ? await scanForTracksFromAlbums(artistAlbums, artistName, "album") : []; const allArtistSinglesTracks = CONFIG.artistMode !== "album" ? await scanForTracksFromAlbums(artistSingles, artistName, "single") : []; return allArtistAlbumsTracks.concat(allArtistSinglesTracks); } async function fetchArtistLikedTracks(uri) { const artistRes = await Spicetify.CosmosAsync.get(`sp://core-collection/unstable/@/list/tracks/artist/${uri}?responseFormat=protobufJson`); const allTracks = artistRes.item?.map((artistTrack) => { if (artistTrack.trackMetadata.playable) return artistTrack.trackMetadata.link; }); return allTracks ?? []; } async function fetchArtistTopTenTracks(uri) { const { queryArtistOverview } = Spicetify.GraphQL.Definitions; const { data, errors } = await Spicetify.GraphQL.Request(queryArtistOverview, { uri, locale: Spicetify.Locale.getLocale(), includePrerelease: false, }); if (errors) throw errors[0].message; return data.artistUnion.discography.topTracks.items.map(({ track }) => track.uri); } async function fetchLikedTracks() { const res = await Spicetify.CosmosAsync.get("sp://core-collection/unstable/@/list/tracks/all?responseFormat=protobufJson"); return res.item.filter((track) => track.trackMetadata.playable).map((track) => track.trackMetadata.link); } async function fetchLocalTracks() { const res = await Spicetify.Platform.LocalFilesAPI.getTracks(); return res.map((track) => track.uri); } function fetchQueue() { const { _queueState } = Spicetify.Platform.PlayerAPI._queue; const nextUp = _queueState.nextUp.map((track) => track.uri); const queued = _queueState.queued.map((track) => track.uri); const array = [...new Set([...nextUp, ...queued])]; const current = _queueState.current?.uri; if (current) array.push(current); return array; } async function fetchCollection(uriObj) { const { category, type } = uriObj; const { pathname } = Spicetify.Platform.History.location; switch (type) { case Type.TRACK: case Type.LOCAL_TRACK: switch (pathname) { case "/collection/tracks": return await fetchLikedTracks(); case "/collection/local-files": return await fetchLocalTracks(); } break; case Type.COLLECTION: switch (category) { case "tracks": return await fetchLikedTracks(); case "local-files": return await fetchLocalTracks(); } } } async function fetchShows(uri) { const res = await Spicetify.CosmosAsync.get(`sp://core-show/v1/shows/${uri}?responseFormat=protobufJson`); return res.items.filter((track) => track.episodePlayState.isPlayable).map((track) => track.episodeMetadata.link); } function shuffle(array) { let counter = array.length; if (counter <= 1) return array; // While there are elements in the array while (counter > 0) { // Pick a random index const index = Math.floor(Math.random() * counter); // Decrease counter by 1 counter--; // And swap the last element with it const temp = array[counter]; array[counter] = array[index]; array[index] = temp; } return array.filter(Boolean); } async function Queue(list, context, type) { const count = list.length; // Delimits the end of our list, as Spotify may add new context tracks to the queue list.push("spotify:delimiter"); const { _queue, _client } = Spicetify.Platform.PlayerAPI._queue; const { prevTracks, queueRevision } = _queue; // Format tracks with default values const nextTracks = list.map((uri) => ({ contextTrack: { uri, uid: "", metadata: { is_queued: "false", }, }, removed: [], blocked: [], provider: "context", })); // Lowest level setQueue method from vendor~xpui.js _client.setQueue({ nextTracks, prevTracks, queueRevision, }); if (context) { const { sessionId } = Spicetify.Platform.PlayerAPI.getState(); Spicetify.Platform.PlayerAPI.updateContext(sessionId, { uri: context, url: `context://${context}`, }); } Spicetify.Player.next(); switch (type) { case Type.ARTIST: if (CONFIG.artistMode === "topTen") { Spicetify.showNotification(`Shuffled Top ${count} Songs`); break; } if (CONFIG.artistMode === "likedSongArtist") { Spicetify.showNotification(`Shuffled ${count} Liked Songs`); break; } if (CONFIG.artistMode === "single") { Spicetify.showNotification(`Shuffled ${artistFetchTypeCount.single} Singles, Total of ${count} Songs`); break; } if (CONFIG.artistMode === "album") { Spicetify.showNotification(`Shuffled ${artistFetchTypeCount.album} Albums, Total of ${count} Songs`); break; } Spicetify.showNotification(`Shuffled ${artistFetchTypeCount.album} Albums, ${artistFetchTypeCount.single} Singles, Total of ${count} Songs`); break; default: Spicetify.showNotification(`Shuffled ${count} Songs`); } artistFetchTypeCount.album = 0; artistFetchTypeCount.single = 0; } async function fetchAndPlay(rawUri) { let list; let context; let type = null; let uri; try { if (rawUri === "queue") { list = fetchQueue(); context = null; } else if (typeof rawUri === "object") { list = rawUri; context = null; } else { const uriObj = Spicetify.URI.fromString(rawUri); type = uriObj.type; uri = uriObj._base62Id ?? uriObj.id; switch (type) { case Type.PLAYLIST: case Type.PLAYLIST_V2: list = await fetchPlaylistTracks(uri); break; case Type.ALBUM: list = await fetchAlbumTracks(rawUri); break; case `${Type.ARTIST}`: if (CONFIG.artistMode === "likedSongArtist") { list = await fetchArtistLikedTracks(uri); break; } if (CONFIG.artistMode === "topTen") { list = await fetchArtistTopTenTracks(rawUri); break; } list = await fetchArtistTracks(rawUri); break; case Type.TRACK: case Type.LOCAL_TRACK: case Type.COLLECTION: list = await fetchCollection(uriObj); break; case Type.FOLDER: list = await fetchFolderTracks(rawUri); break; case Type.SHOW: list = await fetchShows(uri); break; } if (!list?.length) { Spicetify.showNotification("Nothing to play", true); return; } context = rawUri; if (type === "folder" || type === "collection" || type === "local") { context = null; } } await Queue(shuffle(list), context, type); } catch (error) { Spicetify.showNotification(String(error), true); console.error(error); } } })(); ================================================ FILE: Extensions/trashbin.js ================================================ // NAME: Trashbin // AUTHOR: khanhas and OhItsTom // DESCRIPTION: Throw songs to trashbin and never hear it again. /// (function TrashBin() { const skipBackBtn = document.querySelector(".main-skipBackButton-button") ?? document.querySelector(".player-controls__left > button[data-encore-id='buttonTertiary']"); if (!Spicetify.Player.data || !Spicetify.LocalStorage || !skipBackBtn) { setTimeout(TrashBin, 1000); return; } function createButton(text, description, callback) { const container = document.createElement("div"); container.classList.add("setting-row"); container.innerHTML = `
    `; const button = container.querySelector("button.reset"); button.onclick = callback; return container; } function createSlider(name, desc, defaultVal, callback) { const container = document.createElement("div"); container.classList.add("setting-row"); container.innerHTML = `
    `; const slider = container.querySelector("button.switch"); slider.classList.toggle("disabled", !defaultVal); slider.onclick = () => { const state = slider.classList.contains("disabled"); slider.classList.toggle("disabled"); Spicetify.LocalStorage.set(name, state); console.log(name, state); callback(state); }; return container; } function settingsContent() { // Options header = document.createElement("h2"); header.innerText = "Options"; content.appendChild(header); content.appendChild(createSlider("trashbin-enabled", "Enabled", trashbinStatus, refreshEventListeners)); content.appendChild( createSlider("TrashbinWidgetIcon", "Show Widget Icon", enableWidget, (state) => { enableWidget = state; state && trashbinStatus ? widget.register() : widget.deregister(); }) ); // Local Storage header = document.createElement("h2"); header.innerText = "Local Storage"; content.appendChild(header); content.appendChild(createButton("Copy", "Copy all items in trashbin to clipboard.", copyItems)); content.appendChild(createButton("Export", "Save all items in trashbin to a .json file.", exportItems)); content.appendChild(createButton("Import", "Overwrite all items in trashbin via .json file.", importItems)); content.appendChild( createButton("Clear ", "Clear all items from trashbin (cannot be reverted).", () => { trashSongList = {}; trashArtistList = {}; setWidgetState(false); putDataLocal(); Spicetify.showNotification("Trashbin cleared!"); }) ); } function styleSettings() { const style = document.createElement("style"); style.innerHTML = ` .main-trackCreditsModal-container { width: auto !important; background-color: var(--spice-player) !important; } .setting-row::after { content: ""; display: table; clear: both; } .setting-row { display: flex; padding: 10px 0; align-items: center; justify-content: space-between; } .setting-row .col.description { float: left; padding-right: 15px; width: 100%; } .setting-row .col.action { float: right; text-align: right; } button.switch { align-items: center; border: 0px; border-radius: 50%; background-color: rgba(var(--spice-rgb-shadow), .7); color: var(--spice-text); cursor: pointer; display: flex; margin-inline-start: 12px; padding: 8px; } button.switch.disabled, button.switch[disabled] { color: rgba(var(--spice-rgb-text), .3); } button.reset { font-weight: 700; font-size: medium; background-color: transparent; border-radius: 500px; transition-duration: 33ms; transition-property: background-color, border-color, color, box-shadow, filter, transform; padding-inline: 15px; border: 1px solid #727272; color: var(--spice-text); min-block-size: 32px; cursor: pointer; } button.reset:hover { transform: scale(1.04); border-color: var(--spice-text); }`; content.appendChild(style); } function initValue(item, defaultValue) { try { const value = JSON.parse(Spicetify.LocalStorage.get(item)); return value ?? defaultValue; } catch { return defaultValue; } } // Settings Variables - Initial Values let trashbinStatus = initValue("trashbin-enabled", true); let enableWidget = initValue("TrashbinWidgetIcon", true); // Settings Menu Initialization const content = document.createElement("div"); styleSettings(); settingsContent(); const trashbinIcon = ''; const THROW_TEXT = "Place in Trashbin"; const UNTHROW_TEXT = "Remove from Trashbin"; new Spicetify.Menu.Item( "Trashbin", false, () => { Spicetify.PopupModal.display({ title: "Trashbin Settings", content, }); }, trashbinIcon ).register(); const widget = new Spicetify.Playbar.Widget( THROW_TEXT, trashbinIcon, (self) => { const uri = Spicetify.Player.data.item.uri; const uriObj = Spicetify.URI.fromString(uri); const type = uriObj.type; if (!trashSongList[uri]) { trashSongList[uri] = true; if (shouldSkipCurrentTrack(uri, type)) Spicetify.Player.next(); Spicetify.showNotification("Song added to trashbin"); } else { delete trashSongList[uri]; setWidgetState(false); Spicetify.showNotification("Song removed from trashbin"); } putDataLocal(); }, false, false, enableWidget ); // LocalStorage Setup let trashSongList = initValue("TrashSongList", {}); let trashArtistList = initValue("TrashArtistList", {}); let userHitBack = false; const eventListener = () => { userHitBack = true; }; putDataLocal(); refreshEventListeners(trashbinStatus); setWidgetState( trashSongList[Spicetify.Player.data.item.uri], Spicetify.URI.fromString(Spicetify.Player.data.item.uri).type !== Spicetify.URI.Type.TRACK ); function refreshEventListeners(state) { trashbinStatus = state; if (state) { skipBackBtn.addEventListener("click", eventListener); Spicetify.Player.addEventListener("songchange", watchChange); enableWidget && widget.register(); watchChange(); } else { skipBackBtn.removeEventListener("click", eventListener); Spicetify.Player.removeEventListener("songchange", watchChange); widget.deregister(); } } function setWidgetState(state, hidden = false) { hidden ? widget.deregister() : enableWidget && widget.register(); widget.active = !!state; widget.label = state ? UNTHROW_TEXT : THROW_TEXT; } function watchChange() { const data = Spicetify.Player.data || Spicetify.Queue; if (!data) return; const isBanned = trashSongList[data.item.uri]; setWidgetState(isBanned, Spicetify.URI.fromString(data.item.uri).type !== Spicetify.URI.Type.TRACK); if (userHitBack) { userHitBack = false; return; } if (isBanned) { Spicetify.Player.next(); return; } let uriIndex = 0; let artistUri = data.item.metadata.artist_uri; while (artistUri) { if (trashArtistList[artistUri]) { Spicetify.Player.next(); return; } uriIndex++; artistUri = data.item.metadata[`artist_uri:${uriIndex}`]; } } /** * * @param {string} uri * @param {string} type * @returns {boolean} */ function shouldSkipCurrentTrack(uri, type) { const curTrack = Spicetify.Player.data.item; if (type === Spicetify.URI.Type.TRACK) { if (uri === curTrack.uri) { return true; } } if (type === Spicetify.URI.Type.ARTIST) { let count = 1; let artUri = curTrack.metadata.artist_uri; while (artUri) { if (uri === artUri) { return true; } artUri = curTrack.metadata[`artist_uri:${count}`]; count++; } } return false; } /** * * @param {string[]} uris */ function toggleThrow(uris) { const uri = uris[0]; const uriObj = Spicetify.URI.fromString(uri); const type = uriObj.type; const list = type === Spicetify.URI.Type.TRACK ? trashSongList : trashArtistList; if (!list[uri]) { list[uri] = true; if (shouldSkipCurrentTrack(uri, type)) Spicetify.Player.next(); Spicetify.Player.data?.item.uri === uri && setWidgetState(true); Spicetify.showNotification(type === Spicetify.URI.Type.TRACK ? "Song added to trashbin" : "Artist added to trashbin"); } else { delete list[uri]; Spicetify.Player.data?.item.uri === uri && setWidgetState(false); Spicetify.showNotification(type === Spicetify.URI.Type.TRACK ? "Song removed from trashbin" : "Artist removed from trashbin"); } putDataLocal(); } /** * Only accept one track or artist URI * @param {string[]} uris * @returns {boolean} */ function shouldAddContextMenu(uris) { if (uris.length > 1 || !trashbinStatus) { return false; } const uri = uris[0]; const uriObj = Spicetify.URI.fromString(uri); if (uriObj.type === Spicetify.URI.Type.TRACK) { this.name = trashSongList[uri] ? UNTHROW_TEXT : THROW_TEXT; return true; } if (uriObj.type === Spicetify.URI.Type.ARTIST) { this.name = trashArtistList[uri] ? UNTHROW_TEXT : THROW_TEXT; return true; } return false; } const cntxMenu = new Spicetify.ContextMenu.Item(THROW_TEXT, toggleThrow, shouldAddContextMenu, trashbinIcon); cntxMenu.register(); function putDataLocal() { Spicetify.LocalStorage.set("TrashSongList", JSON.stringify(trashSongList)); Spicetify.LocalStorage.set("TrashArtistList", JSON.stringify(trashArtistList)); } function copyItems() { const data = { songs: trashSongList, artists: trashArtistList, }; Spicetify.Platform.ClipboardAPI.copy(JSON.stringify(data)); Spicetify.showNotification("Copied to clipboard"); } async function exportItems() { const data = { songs: trashSongList, artists: trashArtistList, }; try { const handle = await window.showSaveFilePicker({ suggestedName: "spicetify-trashbin.json", types: [ { description: "Spicetify trashbin backup", accept: { "application/json": [".json"], }, }, ], }); const writable = await handle.createWritable(); await writable.write(JSON.stringify(data)); await writable.close(); Spicetify.showNotification("Backup saved succesfully."); } catch { Spicetify.showNotification("Failed to save, try copying trashbin contents to clipboard and creating a backup manually."); } } function importItems() { const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; input.onchange = (e) => { const file = e.target.files[0]; const reader = new FileReader(); reader.onload = (e) => { try { const data = JSON.parse(e.target.result); trashSongList = data.songs; trashArtistList = data.artists; putDataLocal(); Spicetify.showNotification("File Import Successful!"); } catch (e) { Spicetify.showNotification("File Import Failed!", true); console.error(e); } }; reader.onerror = () => { Spicetify.showNotification("File Read Failed!", true); console.error(reader.error); }; reader.readAsText(file); }; input.click(); } })(); ================================================ FILE: Extensions/webnowplaying.js ================================================ // NAME: WebNowPlaying // AUTHOR: khanhas, keifufu (based on https://github.com/keifufu/WebNowPlaying-Redux) // DESCRIPTION: Provides media information and controls to WebNowPlaying-Redux-Rainmeter, but also supports WebNowPlaying for Rainmeter 0.5.0 and older. /// (function WebNowPlaying() { if (!Spicetify.CosmosAsync || !Spicetify.Platform.LibraryAPI) { setTimeout(WebNowPlaying, 500); return; } const socket = new WNPReduxWebSocket(); window.addEventListener("beforeunload", () => { socket.close(); }); })(); class WNPReduxWebSocket { _ws = null; cache = new Map(); reconnectCount = 0; updateInterval = null; communicationRevision = null; connectionTimeout = null; reconnectTimeout = null; isClosed = false; spicetifyInfo = { player: "Spotify Desktop", state: "STOPPED", title: "", artist: "", album: "", cover: "", duration: "0:00", // position and volume are fetched in sendUpdate() position: "0:00", volume: 100, rating: 0, repeat: "NONE", shuffle: false, }; constructor() { this.init(); Spicetify.Player.addEventListener("songchange", ({ data }) => this.updateSpicetifyInfo(data)); Spicetify.Player.addEventListener("onplaypause", ({ data }) => this.updateSpicetifyInfo(data)); } updateSpicetifyInfo(data) { if (!data?.item?.metadata) return; const meta = data.item.metadata; this.spicetifyInfo.title = meta.title; this.spicetifyInfo.album = meta.album_title; this.spicetifyInfo.duration = timeInSecondsToString(Math.round(Number.parseInt(meta.duration) / 1000)); this.spicetifyInfo.state = !data.isPaused ? "PLAYING" : "PAUSED"; this.spicetifyInfo.repeat = data.repeat === 2 ? "ONE" : data.repeat === 1 ? "ALL" : "NONE"; this.spicetifyInfo.shuffle = data.shuffle; this.spicetifyInfo.artist = meta.artist_name; let artistCount = 1; while (meta[`artist_name:${artistCount}`]) { this.spicetifyInfo.artist += `, ${meta[`artist_name:${artistCount}`]}`; artistCount++; } if (!this.spicetifyInfo.artist) this.spicetifyInfo.artist = meta.album_title; // Podcast Spicetify.Platform.LibraryAPI.contains(data.item.uri).then(([added]) => { this.spicetifyInfo.rating = added ? 5 : 0; }); const cover = meta.image_xlarge_url; if (cover?.indexOf("localfile") === -1) this.spicetifyInfo.cover = `https://i.scdn.co/image/${cover.substring(cover.lastIndexOf(":") + 1)}`; else this.spicetifyInfo.cover = ""; } init() { try { this._ws = new WebSocket("ws://localhost:8974"); this._ws.onopen = this.onOpen.bind(this); this._ws.onclose = this.onClose.bind(this); this._ws.onerror = this.onError.bind(this); this._ws.onmessage = this.onMessage.bind(this); } catch { this.retry(); } } close(cleanupOnly = false) { if (!cleanupOnly) this.isClosed = true; this.cache = new Map(); this.communicationRevision = null; if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); if (this.connectionTimeout) clearTimeout(this.connectionTimeout); if (this.ws) { this.ws.onclose = null; this.ws.close(); } } // Clean up old variables and retry connection retry() { if (this.isClosed) return; this.close(true); // Reconnects once per second for 30 seconds, then with a exponential backoff of (2^reconnectAttempts) up to 60 seconds this.reconnectTimeout = setTimeout( () => { this.init(); this.reconnectAttempts += 1; }, Math.min(1000 * (this.reconnectAttempts <= 30 ? 1 : 2 ** (this.reconnectAttempts - 30)), 60000) ); } send(data) { if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; this._ws.send(data); } onOpen() { this.reconnectCount = 0; this.updateInterval = setInterval(this.sendUpdate.bind(this), 500); // If no communication revision is received within 1 second, assume it's WNP for Rainmeter < 0.5.0 (legacy) this.connectionTimeout = setTimeout(() => { if (this.communicationRevision === null) this.communicationRevision = "legacy"; }, 1000); } onClose() { this.retry(); } onError() { this.retry(); } onMessage(event) { if (this.communicationRevision) { switch (this.communicationRevision) { case "legacy": OnMessageLegacy(this, event.data); break; case "1": OnMessageRev1(this, event.data); break; } // Sending an update immediately would normally do nothing, as it takes some time for // spicetifyInfo to be updated via the Cosmos subscription. However, we try to // optimistically update spicetifyInfo after receiving events. this.sendUpdate(); } else { if (event.data.startsWith("Version:")) { // 'Version:' WNP for Rainmeter 0.5.0 (legacy) this.communicationRevision = "legacy"; } else if (event.data.startsWith("ADAPTER_VERSION ")) { // Any WNPRedux adapter will send 'ADAPTER_VERSION ;WNPRLIB_REVISION ' after connecting this.communicationRevision = event.data.split(";")[1].split(" ")[1]; } else { // The first message wasn't version related, so it's probably WNP for Rainmeter < 0.5.0 (legacy) this.communicationRevision = "legacy"; } } } sendUpdate() { if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; switch (this.communicationRevision) { case "legacy": SendUpdateLegacy(this); break; case "1": SendUpdateRev1(this); break; } } } function OnMessageLegacy(self, message) { // Quite lengthy functions because we optimistically update spicetifyInfo after receiving events. try { const [type, data] = message.toUpperCase().split(" "); switch (type) { case "PLAYPAUSE": { Spicetify.Player.togglePlay(); self.spicetifyInfo.state = self.spicetifyInfo.state === "PLAYING" ? "PAUSED" : "PLAYING"; break; } case "NEXT": Spicetify.Player.next(); break; case "PREVIOUS": Spicetify.Player.back(); break; case "SETPOSITION": { // Example string: SetPosition 34:SetProgress 0,100890207715134: const [, positionPercentage] = message.toUpperCase().split(":")[1].split("SETPROGRESS "); Spicetify.Player.seek(Number.parseFloat(positionPercentage.replace(",", "."))); break; } case "SETVOLUME": Spicetify.Player.setVolume(Number.parseInt(data) / 100); break; case "REPEAT": { Spicetify.Player.toggleRepeat(); self.spicetifyInfo.repeat = self.spicetifyInfo.repeat === "NONE" ? "ALL" : self.spicetifyInfo.repeat === "ALL" ? "ONE" : "NONE"; break; } case "SHUFFLE": { Spicetify.Player.toggleShuffle(); self.spicetifyInfo.shuffle = !self.spicetifyInfo.shuffle; break; } case "TOGGLETHUMBSUP": { Spicetify.Player.toggleHeart(); self.spicetifyInfo.rating = self.spicetifyInfo.rating === 5 ? 0 : 5; break; } // Spotify doesn't have a negative rating // case 'TOGGLETHUMBSDOWN': break case "RATING": { const rating = Number.parseInt(data); const isLiked = self.spicetifyInfo.rating > 3; if (rating >= 3 && !isLiked) Spicetify.Player.toggleHeart(); else if (rating < 3 && isLiked) Spicetify.Player.toggleHeart(); self.spicetifyInfo.rating = rating; break; } } } catch (e) { self.send(`Error:Error sending event to ${self.spicetifyInfo.player}`); self.send(`ErrorD:${e}`); } } function SendUpdateLegacy(self) { if (!Spicetify.Player.data && cache.get("state") !== 0) { cache.set("state", 0); ws.send("STATE:0"); return; } self.spicetifyInfo.position = timeInSecondsToString(Math.round(Spicetify.Player.getProgress() / 1000)); self.spicetifyInfo.volume = Math.round(Spicetify.Player.getVolume() * 100); for (const key of Object.keys(self.spicetifyInfo)) { try { let value = self.spicetifyInfo[key]; // For numbers, round it to an integer if (typeof value === "number") value = Math.round(value); // Conversion to legacy values if (key === "state") value = value === "PLAYING" ? 1 : value === "PAUSED" ? 2 : 0; else if (key === "repeat") value = value === "ALL" ? 2 : value === "ONE" ? 1 : 0; else if (key === "shuffle") value = value ? 1 : 0; // Check for null, and not just falsy, because 0 and '' are falsy if (value !== null && value !== self.cache.get(key)) { self.send(`${key.toUpperCase()}:${value}`); self.cache.set(key, value); } } catch (e) { self.send(`Error: Error updating ${key} for ${self.spicetifyInfo.player}`); self.send(`ErrorD:${e}`); } } } function OnMessageRev1(self, message) { // Quite lengthy functions because we optimistically update spicetifyInfo after receiving events. const [type, data] = message.split(" "); try { switch (type) { case "TOGGLE_PLAYING": { Spicetify.Player.togglePlay(); self.spicetifyInfo.state = self.spicetifyInfo.state === "PLAYING" ? "PAUSED" : "PLAYING"; break; } case "NEXT": Spicetify.Player.next(); break; case "PREVIOUS": Spicetify.Player.back(); break; case "SET_POSITION": { const [, positionPercentage] = data.split(":"); Spicetify.Player.seek(Number.parseFloat(positionPercentage.replace(",", "."))); break; } case "SET_VOLUME": Spicetify.Player.setVolume(Number.parseInt(data) / 100); break; case "TOGGLE_REPEAT": { Spicetify.Player.toggleRepeat(); self.spicetifyInfo.repeat = self.spicetifyInfo.repeat === "NONE" ? "ALL" : self.spicetifyInfo.repeat === "ALL" ? "ONE" : "NONE"; break; } case "TOGGLE_SHUFFLE": { Spicetify.Player.toggleShuffle(); self.spicetifyInfo.shuffle = !self.spicetifyInfo.shuffle; break; } case "TOGGLE_THUMBS_UP": { Spicetify.Player.toggleHeart(); self.spicetifyInfo.rating = self.spicetifyInfo.rating === 5 ? 0 : 5; break; } // Spotify doesn't have a negative rating // case 'TOGGLE_THUMBS_DOWN': break case "SET_RATING": { const rating = Number.parseInt(data); const isLiked = self.spicetifyInfo.rating > 3; if (rating >= 3 && !isLiked) Spicetify.Player.toggleHeart(); else if (rating < 3 && isLiked) Spicetify.Player.toggleHeart(); self.spicetifyInfo.rating = rating; break; } } } catch (e) { self.send(`ERROR Error sending event to ${self.spicetifyInfo.player}`); self.send(`ERRORDEBUG ${e}`); } } function SendUpdateRev1(self) { if (!Spicetify.Player.data && cache.get("state") !== "STOPPED") { cache.set("state", "STOPPED"); ws.send("STATE STOPPED"); return; } self.spicetifyInfo.position = timeInSecondsToString(Math.round(Spicetify.Player.getProgress() / 1000)); self.spicetifyInfo.volume = Math.round(Spicetify.Player.getVolume() * 100); for (const key of Object.keys(self.spicetifyInfo)) { try { let value = self.spicetifyInfo[key]; // For numbers, round it to an integer if (typeof value === "number") value = Math.round(value); // Check for null, and not just falsy, because 0 and '' are falsy if (value !== null && value !== self.cache.get(key)) { self.send(`${key.toUpperCase()} ${value}`); self.cache.set(key, value); } } catch (e) { self.send(`ERROR Error updating ${key} for ${self.spicetifyInfo.player}`); self.send(`ERRORDEBUG ${e}`); } } } // Convert seconds to a time string acceptable to Rainmeter function pad(num, size) { return num.toString().padStart(size, "0"); } function timeInSecondsToString(timeInSeconds) { const timeInMinutes = Math.floor(timeInSeconds / 60); if (timeInMinutes < 60) return `${timeInMinutes}:${pad(Math.floor(timeInSeconds % 60), 2)}`; return `${Math.floor(timeInMinutes / 60)}:${pad(Math.floor(timeInMinutes % 60), 2)}:${pad(Math.floor(timeInSeconds % 60), 2)}`; } ================================================ FILE: LICENSE ================================================ GNU LESSER GENERAL PUBLIC LICENSE Version 2.1, February 1999 Copyright (C) 1991, 1999 Free Software Foundation, Inc. 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. [This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. GNU LESSER GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) "Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. 1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) The modified work must itself be a software library. b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. (For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. This option is useful when you wish to copy part of the code of the Library into a program that is not a library. 4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. 5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. 6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. 7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. 10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. 11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. 14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Libraries If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the library `Frob' (a library for tweaking knobs) written by James Random Hacker. , 1 April 1990 Ty Coon, President of Vice That's all there is to it! ================================================ FILE: README.md ================================================

    --- Command-line tool to customize the official Spotify client. Supports Windows, MacOS and Linux. img ### Features - Change colors across the User Interface - Inject CSS for advanced customization - Inject Extensions to extend functionalities, manipulate UI and control player - Inject Custom Apps - Make yourself in control of the Spotify client ### Links - [Installation](https://spicetify.app/docs/getting-started) - [Basic Usage](https://spicetify.app/docs/getting-started#basic-usage) --- ### Code Signing Policy Free code signing provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org/). ================================================ FILE: Themes/SpicetifyDefault/color.ini ================================================ ; COLORS KEYS DESCRIPTION ; text = Main field text; playlist names in main field and sidebar; headings. ; subtext = Text in main sidebar buttons; playlist names in sidebar; artist names and mini infos. ; main = Main field background. ; main-elevated = Backgrounds for objects above the main field. ; highlight = Highlight background for hovering over objects. ; highlight-elevated = Highlight colors for objects above the main field. ; sidebar = Sidebar background. ; player = Player background. ; card = Card background on hover; player area outline. ; shadow = Card drop shadow; button background. ; selected row = Color of selected song, scrollbar, caption and playlist details, download and options buttons. ; button = Playlist button background in sidebar; drop-down menus; now playing song; play button background; like button. ; button-active = Active play button background. ; button-disabled = Seekbar and volume bar background. ; tab-active = Tabbar active item background in header. ; notification = Notification toast. ; notification-error = Error notification toast. ; misc = Miscellaneous. [green-dark] ; Light green on Dark Blue background text = FFFFFF subtext = DEDEDE main = 2E2837 sidebar = 2E2837 player = 2E2837 card = 483b5b shadow = 202020 selected-row = cdcdcd button = 00e089 button-active = 483b5b button-disabled = 535353 tab-active = 483b5b notification = 00e089 notification-error = e22134 misc = BFBFBF [nord-light] text = 2E3440 subtext = 3b4252 main = ECEFF4 sidebar = ECEFF4 player = e5e9f0 card = 88c0d0 shadow = eceff4 selected-row = 9ea4af button = 88c0d0 button-active = d8dee9 button-disabled = c0c0c0 tab-active = d8dee9 notification = 88c0d0 notification-error = e22134 misc = BFBFBF [nord-dark] text = D8DEE9 subtext = ECEFF4 main = 2e3440 sidebar = 2e3440 player = 2e3440 card = 3b4252 shadow = 4c566a selected-row = e5e9f0 button = 5E81AC button-active = 434c5e button-disabled = 434c5e tab-active = 434c5e notification = 5E81AC notification-error = e22134 misc = BFBFBF [pink-white] ; Pink on White background text = 000000 subtext = 3D3D3D main = FAFAFA sidebar = FAFAFA player = FAFAFA card = FE6F61 shadow = F0F0F0 selected-row = 404040 button = FE6F61 button-active = e9e9e9 button-disabled = 535353 tab-active = e9e9e9 notification = FE6F61 notification-error = e22134 misc = BFBFBF [purple] text = FFFFFF subtext = F0F0F0 main = 0A0E14 sidebar = 0A0E14 player = 0A0E14 card = 6F3C89 shadow = 1f1525 selected-row = 909090 button = 6F3C89 button-active = 795b84 button-disabled = 535353 tab-active = 795b84 notification = 6F3C89 notification-error = e22134 misc = BFBFBF [dracula] text = FFFFFF subtext = d8dee9 main = 282a36 sidebar = 282a36 player = 282a36 card = 6272a4 shadow = 44475a selected-row = F0F0F0 button = ffb86c button-active = 44475a button-disabled = 535353 tab-active = 44475a notification = ffb86c notification-error = e22134 misc = BFBFBF ================================================ FILE: Themes/SpicetifyDefault/user.css ================================================ :root { --player-bar-height: 105px; } .main-rootlist-rootlistDividerGradient { background: unset; } input { background-color: unset !important; border-bottom: solid 1px var(--spice-text) !important; border-radius: 0 !important; padding: 6px 10px 6px 48px; color: var(--spice-text) !important; } .x-searchInput-searchInputSearchIcon, .x-searchInput-searchInputClearButton { color: var(--spice-text) !important; } .main-home-homeHeader, .x-entityHeader-overlay, .x-actionBarBackground-background, .main-actionBarBackground-background, .main-entityHeader-overlay, .main-entityHeader-backgroundColor { background-color: unset !important; background-image: unset !important; } .main-playButton-PlayButton.main-playButton-primary { color: white; } .connect-title, .connect-header { display: none; } .connect-device-list { margin: 0px -5px; } /* Remove Topbar background colour */ .main-topBar-background { background-color: unset !important; } .main-topBar-overlay { background-color: var(--spice-main); } .main-entityHeader-shadow, .connect-device-list-container { box-shadow: 0 4px 20px rgba(var(--spice-rgb-shadow), 0.2); } .main-trackList-playingIcon { filter: grayscale(1); } .main-trackList-trackListRow.main-trackList-active:hover .main-trackList-rowMarker, .main-trackList-trackListRow.main-trackList-active:hover .main-trackList-rowTitle, .main-trackList-trackListRow.main-trackList-active:focus-within .main-trackList-rowMarker, .main-trackList-trackListRow.main-trackList-active:focus-within .main-trackList-rowTitle { color: var(--spice-button); } .main-entityHeader-metaDataText, .main-duration-container { color: var(--spice-subtext); } span.artist-artistVerifiedBadge-badge svg:nth-child(1) { fill: black; } /* Full window artist background */ .main-entityHeader-background.main-entityHeader-gradient { opacity: 0.3; } .main-entityHeader-container.main-entityHeader-withBackgroundImage, .main-entityHeader-background, .main-entityHeader-background.main-entityHeader-overlay:after { height: 100vh; } .main-entityHeader-withBackgroundImage .main-entityHeader-headerText { justify-content: center; } .main-entityHeader-container.main-entityHeader-nonWrapped.main-entityHeader-withBackgroundImage { padding-left: 9%; } .main-entityHeader-background.main-entityHeader-overlay:after { background-image: linear-gradient(transparent, transparent), linear-gradient(var(--spice-main), var(--spice-main)); } .artist-artistOverview-overview .main-entityHeader-withBackgroundImage h1 { font-size: 175px !important; line-height: 175px !important; } /** Hightlight selected playlist */ .main-rootlist-rootlistItemLink.main-rootlist-rootlistItemLinkActive { background: var(--spice-button); border-radius: 4px; padding: 0 10px; margin: 0 5px 0 -10px; } .main-navBar-navBarLinkActive { background: var(--spice-button); } div.GlueDropTarget.personal-library > *.active { background: var(--spice-button) !important; } .main-contextMenu-menu { background-color: var(--spice-button-active); } .main-contextMenu-menuHeading, .main-contextMenu-menuItemButton, .main-contextMenu-menuItemButton:not(.main-contextMenu-disabled):focus, .main-contextMenu-menuItemButton:not(.main-contextMenu-disabled):hover { color: var(--spice-text); } .main-playPauseButton-button { background-color: var(--spice-button); color: white; } /** Queue page header */ .queue-queue-title, .queue-playHistory-title { color: var(--spice-text) !important; } /** Cards */ .main-cardImage-imageWrapper { background-color: transparent; } /** Sidebar */ .main-rootlist-rootlistDivider { margin-bottom: 8px; } .main-rootlist-rootlistPlaylistsScrollNode { padding: 0; } #spicetify-playlist-list { padding-top: 8px; } .main-collectionLinkButton-collectionLinkButton .main-collectionLinkButton-icon, .main-collectionLinkButton-collectionLinkButton .main-collectionLinkButton-collectionLinkText { opacity: 1; } .link-subtle { color: var(--spice-text); } /** Player bar */ .main-nowPlayingBar-nowPlayingBar { height: var(--player-bar-height); } /** Buddy bar */ .main-buddyFeed-activityMetadata .main-buddyFeed-artistAndTrackName a, .main-buddyFeed-activityMetadata .main-buddyFeed-username a, .main-buddyFeed-activityMetadata .main-buddyFeed-playbackContextLink { color: var(--spice-text); } ================================================ FILE: biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, "linter": { "enabled": true, "rules": { "recommended": true, "suspicious": { "noExplicitAny": "off", "useIterableCallbackReturn": "off" } } }, "formatter": { "enabled": true, "formatWithErrors": true, "indentStyle": "tab", "indentWidth": 2, "lineWidth": 150 }, "javascript": { "formatter": { "trailingCommas": "es5", "arrowParentheses": "always" } }, "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true, "defaultBranch": "main" } } ================================================ FILE: css-map.json ================================================ { "n8Bz0c0v17whD3KfMdOk": "album-albumPage-sectionWrapper", "HPNSn7d7aZf4nfre61sk": "artist-artistAbout-about", "xaeunxBdlShScWay5mQR": "artist-artistAbout-artistGridContainer", "RHlPX8sl9gW1cmKdm1E9": "artist-artistAbout-artistOverviewContainer", "TPcD_1UTh6DS32DfZxhA": "artist-artistAbout-artistOverviewContent", "Ev5jVfYlqmjaZh6ziail": "artist-artistAbout-artistShelfSpacer", "N3mpQyWevHz7lrgLkOBM": "artist-artistAbout-artistSides", "TxASgfgEtA4JmUkhkNUN": "artist-artistAbout-artistSidesBlock", "PAlX44btlstKkPJfe2uU": "artist-artistAbout-avatar", "k4MNlyGrhsL0qgnENxqh": "artist-artistAbout-backgroundImage", "DRXonbAbVN5Vg9anDL1X": "artist-artistAbout-backgroundImage", "CjnwbSTpODW56Gerg7X6": "artist-artistAbout-bio", "xbKOOJ_NjLijBvdpAudQ": "artist-artistAbout-bio", "TV2j1oIRIkKH_6D1xP82": "artist-artistAbout-bioContainer", "Fzu8dfizHyYedxyDjaQ2": "artist-artistAbout-bioContainer", "NPv26QCDgdnwsPOlYJmQ": "artist-artistAbout-cityBlock", "Q_OUHp7iDNLBcO2ZYI2x": "artist-artistAbout-cityBlock", "mKUj4tXOjumlcv0q9Qta": "artist-artistAbout-close", "MHIOvvlSYRmF7VAJDLWy": "artist-artistAbout-close", "y_GaLKy76zj71zPYkLrs": "artist-artistAbout-columnContainer", "ml8qANY77Ah3fx5VTjoe": "artist-artistAbout-columnContainer", "uhDzVbFHyCQDH6WrWZaC": "artist-artistAbout-container", "jW4eWdr_LUeOXwPpKhWG": "artist-artistAbout-container", "BPDHTIIFueJMvtDPZttw": "artist-artistAbout-content", "ejNsts52hRq0uZcc_NXi": "artist-artistAbout-content", "H3lKAypjVfPoxoQkkCJQ": "artist-artistAbout-events", "XbP5GUst__JF8uVtbozd": "artist-artistAbout-heading", "oMsgQk2LPTIE4yinSHOQ": "artist-artistAbout-image", "e2S7FcJ1QgVLlcUbl2Kn": "artist-artistAbout-merch", "Er5rIejLMh22M0OiF8wH": "artist-artistAbout-merchShelfContent", "iZ_BsoftrZBB78FRQ3Yq": "artist-artistAbout-merchSmall", "NiqTdDnFi5KAXoGukrdo": "artist-artistAbout-modal", "pSn5vOGhTHswAWTC_3tE": "artist-artistAbout-modal", "KUUFCMOavSKiLbBMXu0f": "artist-artistAbout-modalAfterOpen", "hIhnD0UMvYHG6TnluwnX": "artist-artistAbout-modalBase", "Xr7SnQlqJFl6FSMeiSsw": "artist-artistAbout-modalBeforeClose", "yzXX2lW3wGayC_cdup_m": "artist-artistAbout-overview", "fPw1FkQQDEJvxJwTalo5": "artist-artistAbout-popularTracks", "Apg1EshcUJs6y4ITDWwV": "artist-artistAbout-popularTracksBlock", "hdS8jsvbJhsJXHAZlWwz": "artist-artistAbout-postedBy", "OyLgnahIHw63684ABRBF": "artist-artistAbout-postedBy", "KguYS3oppxDrMNpTUJcB": "artist-artistAbout-postedByAvatar", "mM_me6VjRfHLPkqAMqEI": "artist-artistAbout-postedByAvatar", "tLjX9htIKD_OCmEX01UN": "artist-artistAbout-rank", "tQp8UOu8jGduQXUTcv0c": "artist-artistAbout-rank", "ZjfaJlGQZ42nCWjD3FDm": "artist-artistAbout-scrollbars", "pHrwZOFBdT8FNXnmcPPI": "artist-artistAbout-scrollbars", "y72RJ2LhCkAyAfZ8tzUt": "artist-artistAbout-sideBlock", "muHL0_3HjlqTZDoapgc9": "artist-artistAbout-social", "DVWIV41y6daOMjQKR8Zj": "artist-artistAbout-socialsWrapper", "TkEnV1bItcSb11Yb8h7h": "artist-artistAbout-stats", "upZIQtiBy1Tr0ZbvhnSL": "artist-artistAbout-stats", "T_AmQPlZ6wvE819I7A0D": "artist-artistAbout-statsContainer", "ndIZG_atdpv_tBZtqQhk": "artist-artistAbout-statsContainer", "GB_nwkJosBf_NLdZAgAx": "artist-artistDiscography-active", "cFKW94askf6sbyOYlRqe": "artist-artistDiscography-artistName", "Ihlsi5QU3AnIfSgnhorS": "artist-artistDiscography-button", "h8z21OhjsxoY1IvpR1QP": "artist-artistDiscography-cardGrid", "XJrBgo4PVAKtiOwymZAH": "artist-artistDiscography-cardGrid", "Rz6JI4bCxJVeSWe6LtEX": "artist-artistDiscography-firstAlbum", "mtGNWthH4lVSrpVPuOZh": "artist-artistDiscography-firstAlbum", "O61y0bIacmTFxhhbL1Bl": "artist-artistDiscography-headerButtons", "W4CDdWdxBiLFn6s7y3y4": "artist-artistDiscography-headerButtons", "fEvxx8vl3zTNWsuC8lpx": "artist-artistDiscography-headerContainer", "tMm_GLWkp1_AE8JeAU8l": "artist-artistDiscography-headerContainer", "n1EzbHQahSKztskTUAm3": "artist-artistDiscography-headerImage", "zHSMgfu24oyo70WbnxJh": "artist-artistDiscography-headerImage", "hyHkMMynp3uUsmEtOkSN": "artist-artistDiscography-headerMetadata", "dPZaWJR4cdScdIwM6Sjx": "artist-artistDiscography-headerMetadata", "kRb29P1Fo5blFMPRDSdq": "artist-artistDiscography-headerTitle", "tAFzDJjlr14lei2t_KYl": "artist-artistDiscography-headerTitle", "P7nfAtcLXgPiUCJrlUjA": "artist-artistDiscography-topBar", "EvQHNTBhaU3rGCRRlAWj": "artist-artistDiscography-topBar", "mxGhI8Lr7MoGOnFrbsUa": "artist-artistDiscography-topBar", "jOcv0cG2WD_3xsPvxwOe": "artist-artistDiscography-topBar", "QplCuuGSoV4updqTSLq9": "artist-artistDiscography-topBarScrolled", "oxpxKtAxG4E2DCK0AJo9": "artist-artistDiscography-tracklist", "JjWADiu6y90rHjdue6qI": "artist-artistDiscography-tracklist", "QQasfrfLlxtbcdiJP40s": "artist-artistOffers-description", "F6RR_jytWUwdg_jiv0Ss": "artist-artistOffers-icon", "TXVCwyY2iLFPRMSav251": "artist-artistOffers-info", "RmO04klyCgpCtMspkDMH": "artist-artistOffers-item", "crweb403jWwGSZngXpHU": "artist-artistOffers-name", "nMgXBztFcmHCjT0dRQDx": "artist-artistOffers-pic", "N5_bW94EwvHFbWWoRucu": "artist-artistOnTour-condensed", "gyHBzoYLUrTsDmijyGQ7": "artist-artistOnTour-date", "J8F7vs26LDSof1E_qwmN": "artist-artistOnTour-dateDay", "VbaPExA4ISCqk6SuSe2H": "artist-artistOnTour-dateMonth", "P5l28MUwDXvStz4OF0IE": "artist-artistOnTour-elevated", "rTTxX5loXbtazDI0NLAj": "artist-artistOnTour-eventName", "j1MetdstqbpfRVFl1W01": "artist-artistOnTour-info", "viRsT27AlJDd4bZyiH_k": "artist-artistOnTour-item", "h3FvLvcLCaK12strxe2F": "artist-artistOnTour-location", "ERcmpF077M1XT1X0fIxB": "artist-artistOnTour-metadata", "Oq3xjFrLaBAabx847xea": "artist-artistOnTour-onTourShelfGrid", "n46Gs0Xh2zys0a2KwSRS": "artist-artistOnTour-time", "IUF5ct6RQtva_f_DQqg0": "artist-artistOnTour-timeAndVenue", "dvPKZDevT0Llia4bmaJX": "artist-artistOnTour-timeAndVenueText", "RpOhZK9h4ccL8wsEgdwQ": "artist-artistOnTour-title", "kgR8s9v7IzY4G17ZtLbw": "artist-artistOverview-about", "Sl7tzXKh5stpn_y5prU5": "artist-artistOverview-artistOverviewContainer", "BL__GuO2JsHMR6RgNfwY": "artist-artistOverview-artistOverviewContent", "_7rRmILTMMuCiW77ERpvL": "artist-artistOverview-artistShelfRelatedVideos", "jJ1PYBjdJy5plPbVprT1": "artist-artistOverview-artistShelfSpacer", "EsgR8WWJIEa9L2wBAKxg": "artist-artistOverview-artistSides", "_yUo81yeoON6wNqCV_ud": "artist-artistOverview-artistSidesBlock", "qsfp4wSDOv6LBCbFApI1": "artist-artistOverview-events", "hLkn_NwPkY5VIAETCqCK": "artist-artistOverview-heading", "_7MwXzTGa7JMhVlJCeDJ9": "artist-artistOverview-merch", "NOFtFAj4dIAsXQgVHJcA": "artist-artistOverview-merchShelfContent", "ODvt8oN68o6Iy8nxwOuY": "artist-artistOverview-merchSmall", "NLaDALU71zxOtBUbsrfH": "artist-artistOverview-overview", "q9GR66ZTM4HH42Z_qroQ": "artist-artistOverview-popularTracks", "yTBLU_z7yk9Xp_oN48Q2": "artist-artistOverview-popularTracksBlock", "uVh80THccLWIzZYwgUaQ": "artist-artistOverview-sideBlock", "b0NcxAbHvRbqgs2S8QDg": "artist-artistVerifiedBadge-badge", "bn2UNQDs5GLY5rjp5Ljh": "artist-artistVerifiedBadge-fillColor", "CmR9tHJ5ta6oWJlKBm3k": "artist-artistVerifiedBadge-wrapper", "idI9vydtCzXVhU1BaKLw": "artist-followButton-button", "Qq16641w1flRfBavPaAn": "artist-followButton-disabled", "fEbcweEiUPPy2eaIaD3F": "artist-followButton-followed", "wi2HeHXOI471ZOh8ncCG": "artist-popularTrackList-seeMore", "YV8aNrRwNwFaMozM_Bfo": "collection-collection-adBarEnabled", "Hbwta1_s02edtdkgJ7qZ": "collection-collection-AddToPlaylistIcon", "nHGnfkVXCk9YYVitV6eu": "collection-collection-emptyStateContainer", "fLqEiE5HwKr9eFCbJgeu": "collection-collection-header", "tUg9tRrSVbtjHJ4WHnxw": "collection-collection-tabBar", "ZzUvBwE6uj5CAU2pjlQX": "collection-collectionEntityHeroCard-container", "xhPzQCyks9pfizJj_PVK": "collection-collectionEntityHeroCard-descriptionContainer", "DY2YrJ5MH3ddIhZGORBq": "collection-collectionEntityHeroCard-headerContainer", "NGSgqDuaOH2VDApkzoxN": "collection-collectionEntityHeroCard-likedSongs", "QoUF6etsDFiL979QR7aC": "collection-collectionEntityHeroCard-moreText", "d8ifuAZX8mK644AwlRZK": "collection-collectionEntityHeroCard-opacityText", "lBs1v3T3HAPrOxfyWemr": "collection-collectionEntityHeroCard-skeletonRow", "ZXOyJPokUIObMnvTOJvc": "collection-collectionEntityHeroCard-tracksContainer", "kST1INfbHSxzydJffBq_": "collection-collectionEntityHeroCard-yourEpisodes", "VuUpAVlUW_OfMfOcDEID": "collection-searchBar-searchBar", "Agho45p8dD6jGmTpdSlp": "cover-art", "H0HbpIM3UrcupWIAjLWu": "cover-art", "W0TACB7OY0iXtKVOtEhY": "cover-art-auto-height", "dyzKbJJHavNOBo6bGSpK": "cover-art-icon", "zmOtW0vqqn1qpZrtQ_w9": "cover-art-icon", "_bmrtgr4_Tgsoiaz4c85": "cover-art-image", "FqmFsMhuF4D0s35Z62Js": "cover-art-image", "tAcAbTTcWl8G7S0wL4E8": "desktopmodals-aboutSpotifyModal-closeButton", "GSFvITwD84dS2JA62Mtj": "desktopmodals-aboutSpotifyModal-closeButton", "DIO4lfVN1pwro7Yg360w": "desktopmodals-aboutSpotifyModal-container", "UnLGG6p932k7WyjkB9Vo": "desktopmodals-aboutSpotifyModal-container", "_h3KfYcbuvm0TopPfACQ": "desktopmodals-aboutSpotifyModal-content", "KlzblASEYfUfaykBFZgM": "desktopmodals-aboutSpotifyModal-content", "_Hx5g6RUtUkydJULdjOA": "desktopmodals-aboutSpotifyModal-copyright", "XF1XXenkrbdAK2rRoxoU": "desktopmodals-aboutSpotifyModal-copyright", "FNHhAAEYgKErDNsJevtS": "desktopmodals-aboutSpotifyModal-licensing", "Ifnz1lh1jjvqPqJ4KPo8": "desktopmodals-aboutSpotifyModal-licensing", "qsKpcFrhrA8KtuTVIN_y": "desktopmodals-licensesModal-buttonContainer", "uYKs_kQMPOziaeDj877B": "desktopmodals-licensesModal-container", "i8qeSJJVx4PXb7fsvOTd": "desktopmodals-licensesModal-content", "WhIzm3S3R6Ker3XvpYW6": "desktopmodals-licensesModal-licensesFrame", "dn48DOL23H8N3jN80yFW": "desktopmodals-versionStatus-container", "qi0hX8uXrbQyS6tvdDBt": "desktopmodals-versionStatus-container", "O7Pq_TuNMtSn0zigOncV": "desktopmodals-versionStatus-content", "WomzHWnDO_yFyjnkd49P": "desktopmodals-versionStatus-content", "sU_Wp3eWSzMrHnkUmbdI": "desktopmodals-versionStatus-copyButton", "R83hOohwVshnd6bEkDO4": "desktopmodals-versionStatus-copyButton", "YJMECPbMHWgMUs8RFdcV": "folder-folderPage-sectionWrapper", "zogFp9G1AEqb8AKOd5B0": "GenericModal", "I3zkdnuhFFrZ1Rr1BJhb": "GenericModal__overlay", "ISA4xMVe89BiwxjIENN1": "GenericModal__overlay", "cUwQnQoE3OqXqSYLT0hv": "link-subtle", "VUXMMFKWudUWE1kIXZoS": "link-subtle", "rC9xwL4gaksmshIjHbNn": "link-subtle", "iKgf4UDhbRTHxmZSuAEc": "lyrics-lyrics-adLeaderboardIsEnabled", "L9xhJOJnV2OL5Chm3Jew": "lyrics-lyrics-background", "o4GE4jG5_QICak2JK_bn": "lyrics-lyrics-background", "RFThkjLuWfPUO9shrMOZ": "lyrics-lyrics-background", "tr8V5eHsUaIkOYVw7eSG": "lyrics-lyrics-container", "FUYNhisXTCmbzt9IDxnT": "lyrics-lyrics-container", "lofIAg8Ixko3mfBrbfej": "lyrics-lyrics-container", "Q2RPoHcoxygOoPLXLMww": "lyrics-lyrics-contentContainer", "gqaWFmQeKNYnYD5gRv3x": "lyrics-lyrics-contentContainer", "_EzvsrEJ47TI8hxzRoKx": "lyrics-lyrics-contentContainer", "esRByMgBY3TiENAsbDHA": "lyrics-lyrics-contentWrapper", "_Wna90no0o0dta47Heiw": "lyrics-lyrics-contentWrapper", "t_dtt9KL1wnNRvRO_y5L": "lyrics-lyrics-contentWrapper", "iPBJpp5EVkRE24N6vmGA": "lyrics-lyrics-coverTopBar", "SaEkeiyzAoXnWVSDiTR7": "lyrics-lyrics-vocalRemoval", "arY01KDGhWNgzlAHlhpd": "lyrics-lyricsContent-active", "EhKgYshvOwpSrTv399Mw": "lyrics-lyricsContent-active", "_gZrl2ExJwyxPy1pEUG2": "lyrics-lyricsContent-active", "iq4cgi0YEKr6DGaTtzUj": "lyrics-lyricsContent-description", "MEjuIn9iTBQbnCqHpkoQ": "lyrics-lyricsContent-highlight", "aeO5D7ulxy19q4qNBrkk": "lyrics-lyricsContent-highlight", "ve52ddYhoAd3Xf27Zxfm": "lyrics-lyricsContent-highlight", "_LKG3z7SnerR0eigPCoK": "lyrics-lyricsContent-isInteractive", "vapgYYF2HMEeLJuOWGq5": "lyrics-lyricsContent-isInteractive", "FQYXZaa0aDIrse54YlYO": "lyrics-lyricsContent-isInteractive", "NiCdLCpp3o2z6nBrayOn": "lyrics-lyricsContent-lyric", "nw6rbs8R08fpPn7RWW2w": "lyrics-lyricsContent-lyric", "BJ1zQ_ReY3QPaS7SW46s": "lyrics-lyricsContent-lyric", "o69qODXrbOkf6Tv7fa51": "lyrics-lyricsContent-lyric", "kGR_hu4tdj9PnUlSPaRL": "lyrics-lyricsContent-provider", "LomBcMvfM8AEmZGquAdj": "lyrics-lyricsContent-provider", "adSF6zkjcpNDto9qhTdV": "lyrics-lyricsContent-provider", "A3ohAQNHsDIMv2EM3Ytp": "lyrics-lyricsContent-text", "BXlQFspJp_jq9SKhUSP3": "lyrics-lyricsContent-text", "MmIREVIj8A2aFVvBZ2Ev": "lyrics-lyricsContent-text", "HxblHEsl2WX2yhubfVIc": "lyrics-lyricsContent-unsynced", "SruqsAzX8rUtY2isUZDF": "lyrics-lyricsContent-unsynced", "E4q8ogfdWtye7YgotBlN": "main-actionBar-ActionBar", "AKrNYHB1rFTyzXauXAtQ": "main-actionBar-ActionBar", "eSg4ntPU2KQLfpLGXAww": "main-actionBar-ActionBarRow", "WWMs8ddvWoRMkjWcRY2W": "main-actionBar-ActionBarRow", "K06ol8ltPT_atXE_JjUP": "main-actionBar-exploreButton", "pRm6ynptw_JVaNIGT44l": "main-actionBar-exploreButton", "CoLO4pdSl8LGWyVZA00t": "main-actionBarBackground-background", "PkOz5g82CaoKk1J3GX0e": "main-actionBarBackground-background", "us5p2PlqI8IJvxWYakKj": "main-actionBarBackground-background", "GTAFfOA_w5vh_bDaGJAG": "main-actionButtons", "NPvLJSRWfv1Joo8dF0D8": "main-actionButtons", "POrR9X4pOmhyjTr88Eag": "main-actionButtons", "P83IYsmHg0RH_STU1ENU": "main-actionButtons", "WIGgdAaAzrXm7f_loaXi": "main-actionButtons-button", "rRF_r7EyCHjZv058JACi": "main-addButton-active", "SPC4uzYXJmknkCGKpxHw": "main-addButton-active", "Fm7C3gdh5Lsc9qSXrQwO": "main-addButton-button", "RbsCNNM9a0WkFCM2UzBA": "main-addButton-button", "nzUMgk_XBD4uFFgA_LOI": "main-addButton-disabled", "GolAZOzxNIKbfZdIApC_": "main-addButton-helmet", "LuK6ZGXdwxSW92X3FodG": "main-addButton-zoomInAnimation", "uKyQmBSHiTyLvZlrFHLE": "main-appShell-card", "fGX1fnzBkGAAMfMxDXDe": "main-appShell-cards", "uMdsIJ2RlIvMQObefThj": "main-appShell-cardsHeader", "ucE6L4XRyB9F_RXDPVuR": "main-appShell-cardsWrapper", "FKzolQvIeUcXEOsjtw8l": "main-appShell-container", "vIGYXwggXAn1Xc_qD5LA": "main-appShell-logo", "HMyLin1O_6Ae2odnit1u": "main-appShell-mainContent", "qqVgbIIvqVxAT7KPTjM6": "main-appShell-navItems", "D1z00v8mUgOxx_my43Yq": "main-appShell-playbackBar", "rLrSnwagmkEsLoI1InYT": "main-appShell-sideBar", "Fe4_ZnxnNSMxu7vzbntV": "main-appShell-topBar", "tp8rO9vtqBGPLOhwcdYv": "main-avatar-avatar", "LKfKy7bXKmlkMEANVJMS": "main-avatar-avatar", "_4JsFsdrKufWvrC44apn": "main-avatar-avatar", "Xz3tlahv16UpqKBW5HdK": "main-avatar-image", "Xgnpn7u01MvGEhekIGLT": "main-avatar-image", "yzLEMgdzXgAnIZZwMHO1": "main-avatar-piled", "ENopS3htmKy15q_QCR2j": "main-avatar-piledIcon", "m95Ymx847hCaxHjmyXKX": "main-avatar-placeholderTransparent", "KdxlBanhDJjzmHfqhP0X": "main-avatar-placeholderWrapper", "BzunmwrVMyWGpopPJRt2": "main-avatar-withBadge", "YEaaNScT6lyJCVBeQoxd": "main-buddyFeed-actions", "EaZyLTjK9Rd_s6A8aYVw": "main-buddyFeed-actions", "_Xe25F2aC59Kljgqyw3G": "main-buddyFeed-activityMetadata", "j7K7_Zly3G1HS9MlKoao": "main-buddyFeed-addFriendPlaceholder", "gY0qK1gmEdhq5idwQc8C": "main-buddyFeed-addFriendPlaceholderBtn", "cBPbfBWIIPDzhIT6i3ih": "main-buddyFeed-addFriendPlaceholderText", "mthrc5U9wb8F4zMBqlPy": "main-buddyFeed-artistAndTrackName", "dLg5WMgjh1kfYtZ_MnZz": "main-buddyFeed-avatarContainer", "ythYrlFSBm1P_ltHc8e1": "main-buddyFeed-buddyFeed", "t345U9kQY1pF704d79oY": "main-buddyFeed-closeButton", "fjCfUvlYgfexObyYQqFM": "main-buddyFeed-closeButton", "eZCD3dqbvZaABVAhIniT": "main-buddyFeed-closeContainer", "Uoc48ia3df_vZxWCLFDB": "main-buddyFeed-closeContainer", "AzO2ondhaHJntbGy_3_S": "main-buddyFeed-container", "NmPFqFYcYmtFfPShmtj3": "main-buddyFeed-container", "zuwPpHAEtIqahnB2u9NR": "main-buddyFeed-content", "dRp9nXvINyo7LOvJtuUC": "main-buddyFeed-content", "MObmOrMxbQpO10ebAtZA": "main-buddyFeed-emptyBuddyFeed", "gj9SOnCIzruGWAM5m3XO": "main-buddyFeed-friendActivity", "oWQvtc5QZlmB60A9ejJx": "main-buddyFeed-friendsFeedContainer", "nGInMrf62TCFD9MBnEzz": "main-buddyFeed-friendsList", "NdQkQZhcYIEcJnRdAYcQ": "main-buddyFeed-header", "vJNLkecIsGhwDaslvslX": "main-buddyFeed-header", "BeABJha8PrxMcJmlBzcH": "main-buddyFeed-headerTitle", "tli6RLZf7DdPtClCKK6_": "main-buddyFeed-link", "n6rSk6R7nfmSGSgTRR5_": "main-buddyFeed-loadingFriends", "VBQoaks4ZkfIknTKmxXZ": "main-buddyFeed-overlay", "ylTErJiI2Ir_LqmrsaXV": "main-buddyFeed-panelTransition", "kE5DI3BkNlo9zuO7K3aO": "main-buddyFeed-playbackContext", "dvb9y9hcP8sUfMhUASKO": "main-buddyFeed-playbackContextIcon", "hnEmEncF7kB2TnE_Uyja": "main-buddyFeed-playbackContextLink", "Irsd58UNEmDPxdhXKXCs": "main-buddyFeed-playIcon", "AWUxW13rbpNdQkvJJg13": "main-buddyFeed-scrollableArea", "v_YrAYQP6fHG_z0hyg7C": "main-buddyFeed-scrollBarContainer", "scnRZypJsyXH1tjPg6uM": "main-buddyFeed-scrollBarContainer", "gWUxbU2cIHAajHxsVLMZ": "main-buddyFeed-section", "WjW1oRtpaNrY37daDP6Y": "main-buddyFeed-sectionFadeEnter", "C3bqciZSM7rPG_L3ohdC": "main-buddyFeed-sectionFadeEnterActive", "T27BYdtZ9ugE_X_JpP1A": "main-buddyFeed-sectionFadeExit", "RLdiPAZ3grZgi7lfHjXA": "main-buddyFeed-sectionFadeExitActive", "LywlKLgNaEtHZzLM3EX5": "main-buddyFeed-timestamp", "Lcc_yFPFIr7HSbt6aQbQ": "main-buddyFeed-title", "cwcgvNz4amFDKqakIqxc": "main-buddyFeed-titleContainer", "ktMuChpoidaEvECE7y8f": "main-buddyFeed-username", "P7j2kCLc27vLybuzy5XB": "main-buddyFeed-usernameAndTimestamp", "LunqxlFIupJw_Dkx6mNx": "main-card-card", "_hPU2Qo7dicv7cnikDRl": "main-card-card", "aAYpzGljXQv1_zfopxaH": "main-card-cardContainer", "RKstfK7T5nPbsDOYT6sT": "main-card-cardContainer", "tsv7E_RBBw6v0XTQlcRo": "main-card-cardLink", "oboNtEJNVzUoiaCWZRti": "main-card-cardLink", "E1N1ByPFWo4AJLHovIBQ": "main-card-cardMetadata", "K2GmhKpSMGL9aDJbOJrD": "main-card-cardMetadata", "xHz124sSHSCYHecLCTfi": "main-card-cardTitle", "sq_3oR2TTWUK3HEVWaxx": "main-card-cardTitle", "w9Tpa4Y111UM5u1WMkEl": "main-card-DownloadStatusIndicator", "vCaVlmqbg5B36V6PzUsm": "main-card-DownloadStatusIndicator", "XiVwj5uoqqSFpS4cYOC6": "main-card-draggable", "kpO5z7v_Nr22lge440TY": "main-card-hero", "xBV4XgMq0gC5lQICFWY_": "main-card-imageContainer", "YnehZkoE2hpA5Tniju8J": "main-card-imageContainer", "NH6UaoYYe47eIHA2Rmal": "main-card-imageContainerOld", "xxLDW4qlPuNbzB9jIJKc": "main-card-imageContainerOld", "AYwATC_zEPwCkmO1yc8R": "main-card-imageContainerSkeleton", "DcLEXCfwMp91VNH5R4m9": "main-card-imageContainerSkeleton", "UB3cP9wsqoAMAHaeBGDt": "main-card-imagePlaceholder", "r9Oj1LabijReMkOyeUxw": "main-card-newEpisodeIndicator", "XpvI6Yl46lYYdMY6SnvK": "main-card-newEpisodeIndicator", "woJQ5t2YiaJhjTv_KE7p": "main-card-PlayButtonContainer", "EFrqkoAHdmfRItUj8H8H": "main-card-PlayButtonContainer", "zuKTW9yEI9rToECOcWG3": "main-card-PlayButtonContainerVisible", "_0rtnZ9bGhwcfS5kspzw": "main-card-PlayButtonContainerVisible", "iwsv8i7rNxA2c_VvC4CO": "main-card-type", "c6FJaCManaNcchyUwvoL": "main-card-withWaves", "MxnDSnm9KnJu4xcsinKh": "main-card-withWavesWrapper", "QO_OHdGt6X6luPH6_Lfg": "main-cardHeader-hasNewEpisodeIndicator", "Nqa6Cw3RkDMV8QnYreTr": "main-cardHeader-link", "nk6UgB4GUYNoAcPtAQaG": "main-cardHeader-text", "yYflTYbufy7rATGQiZfq": "main-cardImage-circular", "SKJSok3LfyedjZjujmFt": "main-cardImage-image", "g4PZpjkqEh5g7xDpCr2K": "main-cardImage-imageWrapper", "mKWCDBYvel1BrZvMNY09": "main-cardImage-imageWrapper", "qucSNjx66ofSGZDzCuEk": "main-cardSubHeader-isHero", "Za_uNH8nTZ0qCuIqbPLZ": "main-cardSubHeader-root", "RArlOXg8S6l3NgRKrGsO": "main-cardSubHeader-text", "r9YzlaAPnM2LGK97GSfa": "main-collectionLinkButton-collectionLinkButton", "ot6VAZq1Xfbw2Vh8Qt_A": "main-collectionLinkButton-collectionLinkText", "v1Gs5xaHy8aBhUMEobSn": "main-collectionLinkButton-dragEnter", "bFQ9NOIn1bDs8tTH0ebQ": "main-collectionLinkButton-icon", "GGx57b6ZwGgzUeTpynUw": "main-collectionLinkButton-iconWrapper", "zDK1f3nL_R49a1mOvaO1": "main-collectionLinkButton-selected", "MIsUJlamzLYuAlvPbmZz": "main-confirmDialog-button", "g9vSqjD_GCegXg2jn5pM": "main-confirmDialog-button", "X05XDhpQJ7THPHfgbUk1": "main-confirmDialog-buttonContainer", "HqYq0HYtZvHn1QGqPBoz": "main-confirmDialog-buttonContainer", "RVgHI2ejYct8LjT1AO7m": "main-confirmDialog-container", "f2wxfjDHQON3z6amVbrW": "main-confirmDialog-container", "m0yIuS1Q6XRA5R4PNEhl": "main-confirmDialog-overlay", "Z2BqWbMx6LJjXzVWDAbZ": "main-confirmDialog-overlay", "gQoa8JTSpjSmYyABcag2": "main-connectBar-connectBar", "UCkwzKM66KIIsICd6kew": "main-connectBar-connectBar", "T3hkVxXuSbCYOD2GIeQd": "main-connectBar-connected", "DOemiVbX4CbcfgSDqiiJ": "main-connectBar-connected", "GcHojieewpdN1c8vbtwk": "main-connectBar-connecting", "KL8t9WB65UfUEPuTFAhO": "main-content-view", "Cm3jbLBimhqdYEcNTVPj": "main-contextMenu-addToPlaylistSubtitle", "OVv1uDz67TLurN8o6LtQ": "main-contextMenu-addToPlaylistTitle", "EyDGMdJOp8ktTzmRFcQM": "main-contextMenu-clearButton", "pzkhLqffqF_4hucrVVQA": "main-contextMenu-disabled", "jJzb9peSGYsUDKbc5QBy": "main-contextMenu-disabled", "Y98_oiegQgSpY_o7hoKG": "main-contextMenu-disabled", "NmbeQabkSLXf0mTAhSLl": "main-contextMenu-dividerAfter", "pXsnH0sMlWUGBffe24cr": "main-contextMenu-dividerAfter", "yO0rvyTlM0Hu4iDIycZE": "main-contextMenu-dividerAfter", "baIGYewvFQfSAOWmZURZ": "main-contextMenu-dividerAfter", "QgtQw2NJz7giDZxap2BB": "main-contextMenu-dividerBefore", "hYwu7ZpcbQIP6iM5fzAu": "main-contextMenu-dividerBefore", "a18VwKy6bTWgj2tAWVmB": "main-contextMenu-dividerBefore", "jh5Yq5ylGtEpGtbz1UNg": "main-contextMenu-dividerBefore", "pc7Mq0FHh8Nlho4sOJH7": "main-contextMenu-expandButton", "IErtLy9qyhR17riTrzYh": "main-contextMenu-expanded", "qCnh1KNFmroitPazoXOc": "main-contextMenu-expandRight", "ycGdaksV3Z5Y7eav3ZyQ": "main-contextMenu-filterInput", "dJzYRtCWK2U6k08EtqAg": "main-contextMenu-filterInputContainer", "zfZEbT8RJbcAg13pTMDl": "main-contextMenu-filterInputFullWidth", "eG930DCaQXDFqjhxRGIs": "main-contextMenu-filterPlaylistSearch", "i8EjndRQjYlli0aLGYEm": "main-contextMenu-filterPlaylistSearchContainer", "M0E2Al6URHV3iyoDbvi_": "main-contextMenu-highlightedText", "uEPiT_llP0oFNDYu_QZR": "main-contextMenu-loadingContainer", "ibA08TpSVrM0wThmotVd": "main-contextMenu-loadingContainer", "SboKmDrCTZng7t4EgNoM": "main-contextMenu-menu", "k4sYYpEpX2f7RMAPHv3F": "main-contextMenu-menu", "NbcaczStd8vD2rHWwaKv": "main-contextMenu-menu", "wlb3dYO07PZuYfmNfmkS": "main-contextMenu-menu", "LJej9EszIMJShPMMExpj": "main-contextMenu-menu", "re2d5HDzt6T4XBgqcBhi": "main-contextMenu-menuHeading", "qUeWph4VP9DwR4xOfabh": "main-contextMenu-menuHeading", "DuEPSADpSwCcO880xjUG": "main-contextMenu-menuItem", "GqvHlGax1jo6vO9D0wHH": "main-contextMenu-menuItem", "rQ6LXqVlEOGZdGIG0LgP": "main-contextMenu-menuItem", "jzMBaEByD6M9xRmS9mv8": "main-contextMenu-menuItem", "ZTmkuXOdPbjhHuLg5How": "main-contextMenu-menuItem", "wC9sIed7pfp47wZbmU6m": "main-contextMenu-menuItemButton", "SN9k988q2Seb_joCaEny": "main-contextMenu-menuItemButton", "mWj8N7D_OlsbDgtQx5GW": "main-contextMenu-menuItemButton", "niXChlbt7kxslMUdfwu9": "main-contextMenu-menuItemButton", "dx1wWcqtuxz4HubHAyh_": "main-contextMenu-menuItemButton", "PDPsYDh4ntfQE3B4duUI": "main-contextMenu-menuItemLabel", "ctAknuakI8idqf_S9tvT": "main-contextMenu-menuItemLabel", "qUnVdUHbBFoYmLhT0_OC": "main-contextMenu-menuItemLabel", "llTZj1tjDr5ZnIOkKdHv": "main-contextMenu-menuItemStatic", "Cjga8q3TFvtKCu9qfm27": "main-contextMenu-menuItemStatic", "T2BaFHODss7KUUqG8Ryq": "main-contextMenu-overlay", "_PqnQJddudWUtaIxOzo7": "main-contextMenu-searchIcon", "dgc81JRAlkNQTsZae3Bz": "main-contextMenu-searchIconContainer", "iGw72tLfCJI5bdpgf7JQ": "main-contextMenu-subMenuIcon", "IJYRnAk_0OE4UPQABQcl": "main-contextMenu-subMenuIcon", "D7Eob912u_NzU8SZkYPA": "main-contextMenu-subMenuIcon", "twYA62xkZMNWmd5FXvqi": "main-contextMenu-subMenuIcon", "abJqsjKClbniwiTCZ7bC": "main-contextMenu-subMenuLeading", "wtEUrk4Sxa5e3QZhvbrs": "main-contextMenu-subMenuLeading", "X8yW2lJbFCQfV5GjoRwL": "main-contextMenu-tippy", "nYdM55iHFByRTzJUmx9X": "main-contextMenu-tippy", "wRjmZh6e7KCL09qchtC9": "main-contextMenu-tippy", "mph1R_QkS44EPi4lrhxd": "main-contextMenu-tippyEnter", "sNOxi993vqYCWYgo_N3H": "main-contextMenu-tippyEnter", "v5IUMJNPJgol0273zQXD": "main-contextMenu-tippyEnterActive", "z_29i58eLFLK50jAs_4a": "main-contextMenu-tippyEnterActive", "bkFQH4uasL3pXqN9eDSi": "main-contextMenu-tippyWrapper", "ExGt4YQfmcwvVFGM7tpN": "main-contextMenu-trigger", "rVxzkDirgkuRPv5V1HYF": "main-coverSlotCollapsed-container", "GQ5_gIWzIqAfBdmQm8yJ": "main-coverSlotCollapsed-container", "cOaOhDavcy4wvE4llkwl": "main-coverSlotCollapsed-container", "qWcH8e2laY9sYOuCsOAx": "main-coverSlotCollapsed-expandButton", "_9sCL61nGvQFXv2u02jXw": "main-coverSlotCollapsed-expandButton", "DUONoWRlKBDq3Ob0DXda": "main-coverSlotCollapsed-expandButton", "IcyWfMS5VkeOhaI7OWIx": "main-coverSlotCollapsed-navAltContainer", "pE08KWp56_bcLi_DddD9": "main-coverSlotCollapsed-navAltContainer", "jtRqaoDIpIR6fEATUTyY": "main-coverSlotExpanded-container", "LROBF2WtGaVryVpVbSOu": "main-coverSlotExpanded-containerExpanding", "FegbnTtU6poHbemMzmBP": "main-coverSlotExpanded-enter", "i1SMAJ9KRyK_muq63Pmg": "main-coverSlotExpanded-enterActive", "Q4cc5RktWgz2H8_vDrIS": "main-coverSlotExpanded-exitActive", "Fih6l1HD6F3NRrdCEMFE": "main-coverSlotExpanded-expanding", "g6ZgzRfiHjsTLskeyI0J": "main-coverSlotExpandedCollapseButton-collapseButton", "IPVjkkhh06nan7aZK7Bx": "main-createPlaylistButton-button", "q3ABXYJT9JZIzXOOtVuO": "main-createPlaylistButton-createPlaylistIcon", "Bwc9jlVb7HWs8JJupnBB": "main-createPlaylistButton-icon", "J4xXuqyaJnnwS6s2p3ZB": "main-createPlaylistButton-text", "PrhIVExjBkmjHt6Ea4XE": "main-devicePicker-button", "tyZF5iwaJ6J5raHWkxwu": "main-devicePicker-connectBarVisible", "IbmaxRtjqCjqTBpFwCgw": "main-devicePicker-connected", "l79vgNZqvs1q9nw6Q8A2": "main-devicePicker-connecting", "E3EJEgTSJdjF1NrDf9GB": "main-devicePicker-connectingIcon", "INitzTSjokOMEJOc6P2H": "main-devicePicker-controlButton", "hwP4Oum2PB765sb8jigI": "main-devicePicker-devices", "h3qSlLeqACMUaASiKDHa": "main-devicePicker-devicesHeading", "zFqMGX3h5z2CO3f2uEiL": "main-devicePicker-devicesList", "MMjN8VsyKsAlLjFw4RMa": "main-devicePicker-devicesOtherNetworksHeading", "AXkwHpGa_BG7Dy4v7o2V": "main-devicePicker-header", "ppzUk3Yw4Opnu95vCliV": "main-devicePicker-header", "tm3lCLoFzk25Q_df5g5K": "main-devicePicker-heading", "Zb1Lbll48zpXP9k_0m1N": "main-devicePicker-heading", "pXrRjiuo3ZpGwIvDAGzJ": "main-devicePicker-headingAction", "gz7xPnsQVKVMnQY2KjJs": "main-devicePicker-headingConnected", "SzZayhxQEuTPMFgNrLOG": "main-devicePicker-headingContent", "fCe03n0XPP2ljLlWgfh3": "main-devicePicker-headingContent", "TJ5Bjp6vgnWVbh6mGN0n": "main-devicePicker-headingSubtitle", "fMNrKmRiR7PwVComoqfb": "main-devicePicker-headingSubtitle", "YY5a3DPS6akIYNqJdwn3": "main-devicePicker-headingTechIcon", "E9qsFEgJZJnARk6hOCsj": "main-devicePicker-headingTitle", "wC7XHKBOz7EiENs4anj4": "main-devicePicker-headingTitle", "WFRr38dFOxh75JyzSTj5": "main-devicePicker-indicator", "ntdp2T_przGKYzhwYSGz": "main-devicePicker-menuOpen", "LVp1auH4vtD3hvb1s4gl": "main-devicePicker-moreButton", "uWvwXlS0Da1bWsRX6KOw": "main-devicePicker-nowPlayingActiveIcon", "KdGUk56d2SlrvrUsFjB1": "main-devicePicker-nowPlayingActiveIcon", "pUkuSEO5HGdvTiujyI6H": "main-devicePicker-section", "mkQEqlUoJ9kghcMfT49m": "main-devicePicker-section", "IdxmFS96lyE7c5uiTnLM": "main-devicePicker-sectionHeading", "Ci8IvJAESoDM_7t8FZWy": "main-devicePicker-sectionHeading", "HVCCFeUiHVwZVv74p34a": "main-devicePicker-sectionWrapper", "bk509U3ZhZc9YBJAmoPB": "main-devicePicker-tooltip", "YIJxiTuPgMQav316cRqP": "main-devicePicker-tooltip", "b46WEWO1Sc_P4WK5RTg0": "main-devicePicker-tooltipArrow", "ItaYxdM8MiuHsa2VXuGQ": "main-devicePicker-tooltipContent", "CgzneMEIFUgv7Gxkf_pM": "main-devicePicker-tooltipContent", "YUyFyiI58gL8VuLdbOD6": "main-devicePicker-troubleshooting", "bZ9fezHRckzRB7RKhQYv": "main-devicePicker-troubleshooting", "QClIatTm05fvjVzODr8X": "main-devicePicker-troubleshootingItemIcon", "gq10jri_eDZTLB32XBkA": "main-devicePicker-troubleshootingItemIcon", "OTuSSciCS3NJqFG8OKX2": "main-devicePicker-troubleshootingItemSubtitle", "cxUSS5WmBfLzDUfriVLA": "main-devicePicker-troubleshootingItemSubtitle", "AN2rvWrkrs7UsnY12hL8": "main-devicePicker-troubleshootingList", "L6IYWy6qSZEiOh0pp4ZX": "main-devicePicker-troubleshootingList", "Fyc_tPyPKyRIT_59VZ2B": "main-downloadClient-actionContainer", "Foyk_HJx16yh22JYmQ56": "main-downloadClient-container", "UalNRoO1omHtEEniypS5": "main-downloadClient-container", "_w3sHVCUhYgvQar5WNHw": "main-dragAndDrop-dndImage", "vb8kSzIiZbfkwqWZROkW": "main-dragAndDrop-dndImageShelter", "zrvvPyoxE6wQNqnu0yWA": "main-dropDown-dropDown", "FQupgLGfMkp1dOYvUeuQ": "main-dropDown-dropDown", "jmu6DFPvhxRl0wSfmv2O": "main-dropDown-isSafari", "dmKa90mTDwgspMBRMHNX": "main-dropDown-isSafari", "T596E9OFZtarwLZV_Opk": "main-duplicateTrackModal-buttonContainer", "AdF5F5BxQXGeWkfceg9A": "main-duplicateTrackModal-container", "XBJ5gUPLDUdlCFkWV7PZ": "main-duplicateTrackModal-description", "YDfNb_CMrwg2Z6FeLyNu": "main-duplicateTrackModal-title", "MF2rLXp4d_JPNs2t0bbj": "main-editImage-buttonContainer", "CRPBj66L4XSUXfKxADb5": "main-editImage-buttonContainer", "jN7ZUHc7IxpwvWsjb4jo": "main-editImageButton-copy", "vU48ZWHmoYvFFaF3r7US": "main-editImageButton-copy", "w3w0DS8atwcgOQJAKAV2": "main-editImageButton-icon", "L6kTuOfrnQA9bTuOLnhR": "main-editImageButton-icon", "xfQXUkj6ThzTYbfF8ilt": "main-editImageButton-image", "OxzbfYatnoSAuGKOF1Up": "main-editImageButton-image", "Usk1cPR7qbn7RudRN3td": "main-editImageButton-overlay", "VvRwJyaExuSefaBWg8FJ": "main-editImageButton-overlay", "CdHBSRh3RhPwBNIBQtkD": "main-editImageButton-rounded", "PvesGiAqdV7dazP6Qulv": "main-embedWidgetGenerator-active", "M05dfNqaFoVZSopsDt0p": "main-embedWidgetGenerator-active", "oBoIIlKrwQjxXpvOiOa0": "main-embedWidgetGenerator-closeBtn", "Yk_RYwNJgyasJs5coavS": "main-embedWidgetGenerator-closeBtn", "U_nX_1rJoqQSWPJRE6zb": "main-embedWidgetGenerator-code", "BHz0zyI26ES05fSpuj26": "main-embedWidgetGenerator-code", "uUYNnjSt8m3EqVjsnHgh": "main-embedWidgetGenerator-container", "JvgoJtzDPOYDcX4dX4V7": "main-embedWidgetGenerator-container", "IJHNf0vxPSbPE1egoG4N": "main-embedWidgetGenerator-content", "sBqvmlzlMok_45AcZJ0k": "main-embedWidgetGenerator-content", "dbKIqeJaR5jYCnOeeDJt": "main-embedWidgetGenerator-contentCode", "aOFEOf2KdtjWKoECSAtZ": "main-embedWidgetGenerator-contentCode", "VaChXV5vRmHB24UihCAG": "main-embedWidgetGenerator-contentFooter", "db7YZ348Hp38w1pBGvbo": "main-embedWidgetGenerator-contentFooter", "zwGMyD7hN0H1Xx8hV5MH": "main-embedWidgetGenerator-contentHeader", "ePALfjiwnEAylS2dC2Vi": "main-embedWidgetGenerator-contentHeader", "NYCZnquuE8_PvM2bxZ3r": "main-embedWidgetGenerator-contentIframe", "DCmP4sRAZe1WedTXGEoQ": "main-embedWidgetGenerator-contentIframe", "gIsqpLXuGzGw3zRTolrW": "main-embedWidgetGenerator-copyBtn", "cNyP6OMku0Y1k9V6I6YA": "main-embedWidgetGenerator-copyBtn", "MnqXew54JWlqw2TG7vDK": "main-embedWidgetGenerator-copyWrapper", "xJroturP3VLDmhMVmH2Q": "main-embedWidgetGenerator-copyWrapper", "LeDataq3AAGozahFmBJL": "main-embedWidgetGenerator-crossSep", "l5aQDGKG1Ri23cOttct6": "main-embedWidgetGenerator-crossSep", "XPFcOWyZPRej4gH_M5XQ": "main-embedWidgetGenerator-darkControl", "ASWMZEQeEdTpnEB1Fe9c": "main-embedWidgetGenerator-darkControl", "tWZ0D98BTxSAFIgp4_tP": "main-embedWidgetGenerator-dimensionField", "Ojyzxzemrirh385czlR6": "main-embedWidgetGenerator-dimensionField", "TSQvyxomq15adJzfCj3j": "main-embedWidgetGenerator-dimensionLabel", "Xw2qZXdaTtQmBi1C3EZW": "main-embedWidgetGenerator-dimensionLabel", "dNNjNzGLV8tjhIuats54": "main-embedWidgetGenerator-dimensionsContainer", "E4pMVn86RUX_bli2MFc8": "main-embedWidgetGenerator-dimensionsContainer", "bOIRpQiHUAEfp8ntStTo": "main-embedWidgetGenerator-header", "a7vBGhnLeEXqDyzDtLQm": "main-embedWidgetGenerator-header", "UrcPMgHLfxfJesFPrNvJ": "main-embedWidgetGenerator-loadingIndicator", "I95R5oKvjPlRocXK04uq": "main-embedWidgetGenerator-loadingIndicator", "nT444OUxO6Vammm8GUVl": "main-embedWidgetGenerator-startAt", "SfLe8NXeES5ls02VNodp": "main-embedWidgetGenerator-startAt", "wVDmvGlxMo4VisD89XUI": "main-embedWidgetGenerator-terms", "RONPdb2SVzKH9L0MI7m9": "main-embedWidgetGenerator-terms", "mKjr_3b27jFzEoJBJYQw": "main-embedWidgetGenerator-theme", "mA2povcLaNEMTwFkbkRA": "main-embedWidgetGenerator-theme", "gAlrxXSfi5DpMcVMiUsz": "main-embedWidgetGenerator-themeDescription", "dwoJTbHyEYJwtW9Sv_sL": "main-embedWidgetGenerator-themeDescription", "mBGUavHPMlD5mfAmiy5g": "main-embedWidgetGenerator-themeRadio", "aNmXQxrSRjmN6iz25rzV": "main-embedWidgetGenerator-themeRadio", "bps2gHcEE9dOV6djVLWF": "main-embedWidgetGenerator-timestampInput", "sd7sAILjjhbR8VcoTtD1": "main-embedWidgetGenerator-timestampInput", "h7qbIvm2yIiZVYqONyFG": "main-embedWidgetGenerator-tooltip", "fV9zaLzqxhNrDHsShUyv": "main-embedWidgetGenerator-tooltip", "xwNumE3szip6EhU74j3f": "main-embedWidgetGenerator-tooltipInitiator", "_zl1TqB0dymzANa9Mg38": "main-embedWidgetGenerator-tooltipInitiator", "ObwDQms6Cu8h_8FMWzAT": "main-embedWidgetGenerator-transControl", "itbtoqlEj6jmbrhNsVgo": "main-embedWidgetGenerator-transControl", "gB6AcMixPEmdr96SUSBM": "main-embedWidgetGenerator-visible", "_7n3LwjOANBxLFcGULJMg": "main-embedWidgetGenerator-visible", "P4iG24p5ttZDxkQJsiDb": "main-embedWidgetGenerator-widthField", "sP9LYlDy3KkYglIvABtL": "main-embedWidgetGenerator-widthField", "MyW8tKEekj9lKQsviDdP": "main-entityHeader-background", "i_kMOBXfnweAkv8s97Yc": "main-entityHeader-background", "wozXSN04ZBOkhrsuY5i2": "main-entityHeader-background", "gHImFiUWOg93pvTefeAD": "main-entityHeader-backgroundColor", "PeLrpasyfBW8ql_bmoAi": "main-entityHeader-backgroundColor", "XVz4BMGP5zAEE5p90mYK": "main-entityHeader-backgroundColor", "NIEO4GCY9P49NrtTWlhP": "main-entityHeader-backgroundColor", "LzVINwqiLdMt_bgS5psf": "main-entityHeader-backgroundColor", "H0vWBc23fJOetym6NudG": "main-entityHeader-bold", "ta4ePOlmGXjBYPTd90lh": "main-entityHeader-circle", "NXiYChVp4Oydfxd7rT5r": "main-entityHeader-container", "tmHIlrxw7_8S0lWnceeq": "main-entityHeader-container", "kuSGKO1BuKZ3fgas7_T7": "main-entityHeader-creatorButton", "NO_VO3MRVl9z3z56d8Lg": "main-entityHeader-creatorWrapper", "bMmO2GCdsRzxLgVMSGvM": "main-entityHeader-creatorWrapper", "Ydwa1P5GkCggtLlSvphs": "main-entityHeader-detailsText", "n4hTP7ZeAOT_UQEkRUR7": "main-entityHeader-divider", "k2I8B0MzXkAJ6_s8okM7": "main-entityHeader-gradient", "sv5suqIPUwjgUF_BzM41": "main-entityHeader-gradient", "XUwMufC5NCgIyRMyGXLD": "main-entityHeader-gradient", "PUIUCdIR_h05BC2EDgIP": "main-entityHeader-gray", "RP2rRchy4i8TIp1CTmb7": "main-entityHeader-headerText", "c55UACltdzzDDQVfoF18": "main-entityHeader-headerText", "CmkY1Ag0tJDfnFXbGgju": "main-entityHeader-image", "bFtVZZnZgTWjjyzkPA5k": "main-entityHeader-image", "_gLjHpwOxHFwo5nLM8hb": "main-entityHeader-imageContainer", "D0QehabsepiTU_FhDZOQ": "main-entityHeader-imageContainer", "GoU8CT9Vm_TP_LyYJTsf": "main-entityHeader-imageContainerClickable", "_osiFNXU9Cy1X0CYaU9Z": "main-entityHeader-imageContainerNew", "ylr6LvLf5L8yLiqErDES": "main-entityHeader-imageContainerNew", "MclJZ2TMkhjdlqqQfmQd": "main-entityHeader-imagePlaceholder", "C7eyib8lynZrycU2Eh_A": "main-entityHeader-large", "k9LEjzjnGgVaEm3BvhAb": "main-entityHeader-largeHeader", "Jnt9_XezZCMkZmtgBtlL": "main-entityHeader-medium", "bQy_F9QVENlCAL8Qan9_": "main-entityHeader-mermaidGradientOverlay", "Fb61sprjhh75aOITDnsJ": "main-entityHeader-metaData", "blfR_YJUsKUvdgTejBSb": "main-entityHeader-metaData", "JWDnag2Mepdf9QE0cNbg": "main-entityHeader-metaData", "Yc6ftz7mCqbsTN4ha_ke": "main-entityHeader-metaDataAuthor", "RANLXG3qKB61Bh33I0r2": "main-entityHeader-metaDataText", "NULzZTkd4w0TSVS4HKux": "main-entityHeader-metaDataText", "w1TBi3o5CTM7zW1EB3Bm": "main-entityHeader-metaDataTextSubdued", "gRs15KFAiBRH4FFbVvVr": "main-entityHeader-metaDataTextSubdued", "lD5bMttyrRNzsKch6ysa": "main-entityHeader-newEntityHeaders", "ByDYHUfzUKVgFwT5bTxp": "main-entityHeader-newEntriesIndicator", "RMDSGDMFrx8eXHpFphqG": "main-entityHeader-nonWrapped", "xYgjMpAjE5XT05aRIezb": "main-entityHeader-overlay", "I0bVSxvqA3rm5HvciMap": "main-entityHeader-overlay", "yhlH4Dsjqw56Z58EOwvQ": "main-entityHeader-overlay", "dhq75ObRGjfBoBlz6clW": "main-entityHeader-overlay", "ehHB7Xk2_FPwYofqZ9k2": "main-entityHeader-overlay", "YW4dYEf5ZuLzMfSjsqZk": "main-entityHeader-piled", "lp9Tfm4rsM9_pfbIE0zd": "main-entityHeader-pretitle", "K4l4RICNZv9cxx9ag0u5": "main-entityHeader-pretitle", "YbDIZ84mS7tzHr1tgWE9": "main-entityHeader-roundedCorners", "_EShSNaBK1wUIaZQFJJQ": "main-entityHeader-shadow", "VPnrctjNWVzCtyD7DZAG": "main-entityHeader-shadow", "D5X2O0j5dhTZJIkgH8mz": "main-entityHeader-small", "U1ypKorrS1qiWD1uQpAD": "main-entityHeader-smallHeader", "zjsGbrMpvbdA1HJ4rpfi": "main-entityHeader-smallMadeForIcon", "gSx70PISJg6PSRafbOXd": "main-entityHeader-subtitle", "YxJ3zwH0R8K8njVQgMcw": "main-entityHeader-subtitleButton", "RmWKJG2G0fTrx11zKv_j": "main-entityHeader-theyFollowUs", "rEN7ncpaUeSGL9z0NGQR": "main-entityHeader-title", "__NC_butOiOksXo2E3M1": "main-entityHeader-title", "wCkmVGEQh3je1hrbsFBY": "main-entityHeader-titleButton", "vamOGPv1eDxaQS4qflcg": "main-entityHeader-titleButton", "o4KVKZmeHsoRZ2Ltl078": "main-entityHeader-titleInner", "vdZMPj4FaYLWBoyRTzBQ": "main-entityHeader-titleInner", "HcA9WjbLc4x02X8Ty0uO": "main-entityHeader-topbarContent", "URymDGVda51vMGGEWsH1": "main-entityHeader-topbarContent", "lro6AjUrZFH6zxjmOGg0": "main-entityHeader-topbarContentFadeIn", "ITu7QAFWEP4R0HMkDOMU": "main-entityHeader-topbarContentFadeIn", "G7zO58ORUHxcUw0sXktM": "main-entityHeader-topbarTitle", "BOjeWsq7rAtXYUaR86bq": "main-entityHeader-topbarTitle", "E0MlERsQ_zvY3BX7ZzPp": "main-entityHeader-uppercase", "L29j6que7xOiNSH_EOvQ": "main-entityHeader-wavesBackground", "XPjEhsPyuOvMZ9NsDrxT": "main-entityHeader-withBackgroundImage", "DFuKwT5bvhKZoYr2gfZe": "main-entityHeader-withBackgroundImage", "KAZD28usA1vPz5GVpm63": "main-genericButton-button", "pJ7RQa2Lqdi9JOvfKGAA": "main-genericButton-button", "RK45o6dbvO1mb0wQtSwq": "main-genericButton-buttonActive", "eFrQ8hr8h9gs3tIFTSWV": "main-genericButton-buttonActive", "EHxL6K_6WWDlTCZP6x5w": "main-genericButton-buttonActiveDot", "WOIKebyj47byTnTaucoA": "main-genericButton-buttonActiveDot", "OomFKn3bsxs5JfNUoWhz": "main-globalNav-buddyFeed", "VizXsWMIuNfKGN5pMyox": "main-globalNav-historyButtons", "K1Ve0b6y28X7myMURsKS": "main-globalNav-historyButtons", "KkJlQWSJM6Cu2GJJSBQ7": "main-globalNav-historyButtons", "Z6t_8rA6LOBrX3huqRJG": "main-globalNav-historyButtonsContainer", "pIM9jg__39NIpOvXG89b": "main-globalNav-historyButtonsContainer", "VphhNp8Q7R2U552LryhP": "main-globalNav-historyButtonsContainer", "rBX1EWVZ2EaPwP4y1Gkd": "main-globalNav-icon", "jdlOKroADlFeZZQeTdp8": "main-globalNav-link-icon", "dIfr5oVr5kotAi0HsIsW": "main-globalNav-link-icon", "bWBqSiXEceAj1SnzqusU": "main-globalNav-navLink", "obd_bH64Snp1npdw29XM": "main-globalNav-navLink", "YEAFPNm87XbzS4sF5dDe": "main-globalNav-navLink", "voA9ZoTTlPFyLpckNw3S": "main-globalNav-navLinkActive", "ETjtwGvAB4lRVqSzm8nA": "main-globalNav-navLinkActive", "AonZ39aVKATRTjY28Uww": "main-globalNav-navLinkActive", "Ufz621LN174DTRDis7EY": "main-globalNav-navLinkActive", "QrpHSphgBSqzODEHqr_t": "main-globalNav-searchContainer", "lj0eGI6WEtfxFX7irC03": "main-globalNav-searchContainer", "v8JHoFMumOgbCn8vsTvC": "main-globalNav-searchContainer", "fksI89zEXwqKWm1O6sJm": "main-globalNav-searchInputContainer", "W9VXOkC43GP_7ULClgxQ": "main-globalNav-searchInputContainer", "b7r2WRiu5f9Q99qmyreh": "main-globalNav-searchInputContainer", "RpMCAf8TjF6HpQSI7hdx": "main-globalNav-searchInputContainer", "PvsV2JgJRDME1vDn6IJL": "main-globalNav-searchInputSection", "tDFP1X98EgqQIPaumxRt": "main-globalNav-searchInputSection", "_b3hhmbWtOY8_1M1mM1H": "main-globalNav-searchInputSection", "NykkfCmZVlyRYNAqZg35": "main-globalNav-searchInputSection", "W02pQCvfy5Bin7z4EAzo": "main-globalNav-searchSection", "gj5VcIUC9oD2p4BsxzGE": "main-globalNav-searchSection", "jGghIqFVK6VrBUP9FLIq": "main-globalNav-searchSection", "soGhxDX6VjS7dBxX9Hbd": "main-gridContainer-fixedWidth", "iKwGKEfAfW7Rkx2_Ba4E": "main-gridContainer-gridContainer", "dZZDxz44v5EOD33wbCZ3": "main-gridContainer-gridContainer", "PmW2UCL9vlTcNPD7J0KJ": "main-gridContainer-uniformRowHeight", "HkbHLcqgUfXruL5xVi28": "main-heroCard-card", "_gB1lxCfXeR8_Wze5Cx9": "main-heroCard-cardLink", "sm7ZnbOO1Zfg9cupYgPN": "main-heroCard-cardMetadata", "kVHQenQh3yKEk2n7Ere4": "main-heroCard-draggable", "NkDkQMd75JY5xes9xFVe": "main-heroCard-isDownloadable", "liYe8rZ0FEQBy8j8XGJH": "main-heroCard-isPlaying", "pgwIORyBdf4nbb4G5_Jx": "main-heroCard-PlayButtonContainer", "I3EivnXTjYMpSbPUiYEg": "main-home-content", "ZLSAuA1tn0bSdQRwhhj6": "main-home-content", "Le0q6vXGEvilJEjOqgF9": "main-home-content", "YDYIuDcqNWY3gq9hxh5P": "main-home-content", "zbU90jX5VWUhVlpUda7B": "main-home-filterChipsContainer", "cj6vRk3nFAi80HSVqX91": "main-home-filterChipsContainer", "c8Z2jJUocJTdV9g741cp": "main-home-filterChipsContainer", "rX_OmqCngvY5ZCoYBZgb": "main-home-filterChipsSection", "hIFR8WDm_54EEIa1gwpC": "main-home-filterChipsSection", "aBTcK0jHBextE7fGnKiw": "main-home-filterChipsSection", "uIJTvxFOg2izOY7aRRiU": "main-home-home", "HsbczDqu9qjcYr7EIdHR": "main-home-homeHeader", "S4OmZ_IZexmZ5dasPqW5": "main-home-homeHeader", "HnVkTECZ2a98QALFTkdq": "main-home-homeHeader", "DQRst2WAECYq2xsrsPRA": "main-home-isOffline", "QbD9zl7z3AhEQu_TGmo8": "main-home-subfeedSection", "_kVOt2H6WxXzURx2FRLM": "main-home-withAds", "LBM25IAoFtd0wh7k3EGM": "main-image-image", "mMx2LUixlnN_Fu45JpFB": "main-image-image", "Yn2Ei5QZn19gria6LjZj": "main-image-loaded", "PgTMmU2Gn7AESFMYhw4i": "main-image-loaded", "yOKoknIYYzAE90pe7_SE": "main-image-loading", "wcftliF4QjZKB1CYgEON": "main-imagePicker-fileInput", "IgNI7rYsyjJ5_Xtpap4a": "main-imagePicker-image", "IuU_JLhFTfKPXfDkmAaF": "main-keyboardShortcutsHelpModal-closeBtn", "YyxAoeTuu0BHU1OcahFK": "main-keyboardShortcutsHelpModal-container", "EhyK_jJzB2PcWXd5lg24": "main-keyboardShortcutsHelpModal-container", "e4ETsc5zxjzyF9nyb4LI": "main-keyboardShortcutsHelpModal-header", "hykQHtPI6EeFREwqRrOR": "main-keyboardShortcutsHelpModal-key", "ARw2f2PkF29n9Ek_eWu3": "main-keyboardShortcutsHelpModal-sectionHeading", "umavpIt6VOGqirdlUYWs": "main-keyboardShortcutsHelpModal-sectionItem", "dYBZmh_ZIyvBZfaoducd": "main-keyboardShortcutsHelpModal-sectionItemName", "KDlcc1SFTcA90eMUcn5P": "main-keyboardShortcutsHelpModal-sections", "cyXplMovoowBozEe4r2x": "main-keyboardShortcutsHelpModal-sectionsContainer", "X871RxPwx9V0MqpQdMom": "main-leaderboardComponent-container", "Nd_DeCpszONzyaLe5Wd1": "main-likedSongsButton-likedSongsIcon", "BuzoTjBZd1UqCn6DmFJr": "main-loadingIndicator-circle", "HKamyJi9H31s99erfVyG": "main-loadingIndicator-loadingIcon", "jM1dnq2_qjViaXxa6WD7": "main-loadingIndicator-loadingIcon", "K7fGF95OD9aI3zdYnFXg": "main-loadingPage-container", "AcuplATLhi6LNeu6_PK7": "main-loadingPage-container", "y7xcnM6yyOOrMwI77d5t": "main-lyricsCinema-container", "TITRkcJffQbL60GWevgh": "main-lyricsCinema-content", "xnPS9Fa6efctzBjM05O4": "main-lyricsCinema-controls", "AptbKyUcObu7QQ1sxqgb": "main-lyricsCinema-lyricsCinemaVisible", "YtsqA6txqmCqkKq0G2Ta": "main-lyricsCinema-nonDisplayedArea", "T0anrkk_QA4IAQL29get": "main-moreButton-button", "NyIynkmMpZXSoaE3XGhA": "main-navBar-banner", "WvLkmOVB2R2vzI2ibR_r": "main-navBar-downloadItem", "RSg3qFREWrqWCuUvDpJR": "main-navBar-entryPoints", "WJsKJXEbycxxq8OcGHM1": "main-navBar-logo", "sqKERfoKl4KwrtHqcKOd": "main-navBar-mainNav", "lYpiKR_qEjl1jGGyEvsA": "main-navBar-mainNav", "F2o99ns3FQFoDThE7QiV": "main-navBar-mainNav", "tUwyjggD2n5KvEtP5z1B": "main-navBar-navBar", "eNs6P3JYpf2LScgTDHc6": "main-navBar-navBarItem", "b2KVTiBUcXV1kT0OjL2p": "main-navBar-navBarItemDropTarget", "ATUzFKub89lzvkmvhpyE": "main-navBar-navBarLink", "moDRd9td0KtitPDzR7OJ": "main-navBar-navBarLinkActive", "GKnnhbExo0U9l7Jz2rdc": "main-navBar-premiumLink", "uhxXhw9alI7KR1YTc904": "main-navBar-premiumNavItem", "IebnAuNOhIG5mDVNJQ5M": "main-noConnection-isError", "N3juGUCH1EhEzmffNHAp": "main-noConnection-isNotice", "w3PliL6VjTaj2VfscF_k": "main-notificationBubble-closeIcon", "ZKFK00olAYy6LtYMjEIa": "main-notificationBubble-enter", "QlivAoYbLCn5nS3zU331": "main-notificationBubble-enterActive", "o_GEOPl4MEZXycGnTN69": "main-notificationBubble-exit", "oJET9u4Y7Vz69Mpp5syC": "main-notificationBubble-exitActive", "w_XZqc9pSOOjMMBej0c9": "main-notificationBubble-horizontal", "dvZM2BeYewZHdINeWuh6": "main-notificationBubble-isClickable", "_cDysv1Z9Ihw848shb92": "main-notificationBubble-isCloseable", "sgWxJyCSuqvdP5VZhqts": "main-notificationBubble-isError", "hmqCvVdVEXlW52i3efqa": "main-notificationBubble-isNotice", "vQNZsbpzoUFSW5fsFOAw": "main-notificationBubble-NotificationBubble", "mtmSGKkBYNwbd49V_8fS": "main-notificationBubble-withPointer", "zrZgCe7tURBRdKkFGc7S": "main-notificationBubble-withSidePointer", "a3nLBllaudGWnYzNTND_": "main-notificationBubble-withTopLeftPointer", "_XeODCkWznZi5csbdPAe": "main-notificationBubble-withTopRightPointer", "AOaoydTb5lrGytHbTAAy": "main-notificationBubbleContainer-NotificationBubbleContainer", "wh7q2LxxjhyXLJvxRQGG": "main-nowPlayingBar-buddyFeedIcon", "E526E3G50lCRjDpGVG5B": "main-nowPlayingBar-center", "P4eSEARM2h24PZxMHz1T": "main-nowPlayingBar-center", "sVv2OQORCQ4kf6iKfUTF": "main-nowPlayingBar-center", "fFvsrUhS_NiwbMFmj0fB": "main-nowPlayingBar-container", "GD2gbRtcs5dOjMGAM_Y4": "main-nowPlayingBar-container", "yglmI5m3fCc8baD1Kwdw": "main-nowPlayingBar-container", "RtrMo_s0acvzbSRLovVW": "main-nowPlayingBar-enter", "D4el1GTrNd7l_TFyRGO8": "main-nowPlayingBar-enterActive", "HOZJyu9UCJodOQSVvduV": "main-nowPlayingBar-enterDone", "mwpJrmCgLlVkJVtWjlI1": "main-nowPlayingBar-extraControls", "NClDR4CG_J8nuqy2uGn9": "main-nowPlayingBar-extraControls", "tT6x7wFZmjldiCeh6HzO": "main-nowPlayingBar-isAnonymous", "KkmXuF5h8DmzQUwGhT2u": "main-nowPlayingBar-left", "OgkbKIVYE_mrNpYESylB": "main-nowPlayingBar-left", "snFK6_ei0caqvFI6As9Q": "main-nowPlayingBar-left", "Xmv2oAnTB85QE4sqbK00": "main-nowPlayingBar-lyricsButton", "OB4Vm26X_3tYkgqkKrDm": "main-nowPlayingBar-lyricsButton", "DLwH4stkW06ZbHFstpq0": "main-nowPlayingBar-nowPlayingBar", "OCY4jHBlCVZEyGvtSv0J": "main-nowPlayingBar-nowPlayingBar", "udArIAqnfUQPQew2VAns": "main-nowPlayingBar-nowPlayingBar", "Y6soMMBElF7EQDbJv8Xb": "main-nowPlayingBar-right", "jOKLc29vP0Bz1K0TsDtX": "main-nowPlayingBar-right", "pLifNBuHRY8cZkZyEqwL": "main-nowPlayingBar-right", "uVRRxsH6RKj3Dzhl40Ok": "main-nowPlayingBar-topButton", "ExuDUBJ7bk8vT6INnm9F": "main-nowPlayingBar-volumeBar", "niz7IVQi5Id4arbkuPmM": "main-nowPlayingBar-volumeBar", "U3kNTAyv7lhF9nBuwgB6": "main-nowPlayingView-aboutArtist", "muTn937T_T9l0xqjlN8A": "main-nowPlayingView-aboutArtistBio", "Tk7SUvI_ULiUuC5gZsIx": "main-nowPlayingView-aboutArtistButton", "ldx08BCI74rTPhgD3vbj": "main-nowPlayingView-aboutArtistContent", "HISuyqmMLx0amzWHxgN1": "main-nowPlayingView-aboutArtistHasImage", "RJvcrFChbhxGRIi8mBXJ": "main-nowPlayingView-aboutArtistPlaceholderWrapper", "DjnRJuC2FcNrQ6Q6DerZ": "main-nowPlayingView-aboutArtistTextContent", "vbsB4OQJkFHLU8SbSGzS": "main-nowPlayingView-aboutArtistV2", "jLPxNlznfpZHUtITCFnb": "main-nowPlayingView-aboutArtistV2Avatar", "r9m6lHy7RyIPDzW1Youe": "main-nowPlayingView-aboutArtistV2Bio", "hd6a3g_3QyF8MFL0wWs1": "main-nowPlayingView-aboutArtistV2Button", "kVP43jHrJeS7afn8mOgX": "main-nowPlayingView-aboutArtistV2FollowButton", "z9CDQr2gnyXDtcc1uF05": "main-nowPlayingView-aboutArtistV2HasImage", "ouorHKa6NI5cm666H3tp": "main-nowPlayingView-aboutArtistV2Image", "GTmlByXpJj7V6AwVq0Vk": "main-nowPlayingView-aboutArtistV2ImageContainer", "iWpZp7Ab_9h7s_U1SsLN": "main-nowPlayingView-aboutArtistV2Listeners", "zhQX2DOI2muMo8EKsZ6h": "main-nowPlayingView-aboutArtistV2ListenersCount", "COJ84QbXPrd4jkO1HU2N": "main-nowPlayingView-aboutArtistV2Name", "yIPdY6L6pcwR4L5Xf0vY": "main-nowPlayingView-aboutArtistV2PlaceholderWrapper", "QkOkUShDYWFx5Cz40Bcn": "main-nowPlayingView-aboutArtistV2TextContent", "vkS_Ks0svKls4w2s2ppT": "main-nowPlayingView-aboutArtistV2Title", "IgTMXVbZtqtZwu3GZASd": "main-nowPlayingView-artistOnTour", "uvIvZ4XqfEFs88BAPaI8": "main-nowPlayingView-artistOnTourItem", "svHFeMC3Ef_TpSdRyvsM": "main-nowPlayingView-artistOnTourShowAll", "QIuMX9iPlMiflBPUkrEQ": "main-nowPlayingView-container", "jtqtOeRP46XAlHWx4C0D": "main-nowPlayingView-content", "aaFQbW0j0N40v_siz0kX": "main-nowPlayingView-contextItemInfo", "UUydeXMsXVZofB0YAOgm": "main-nowPlayingView-contextItemInfo", "j9I5h3Z4o0fKNgI1fIjb": "main-nowPlayingView-coverArt", "T5w6KXWFZ5aBsuquOADG": "main-nowPlayingView-coverArt", "zL6hQR4mukVUUQaa_7K1": "main-nowPlayingView-coverArtContainer", "pRIQxez4Q9UdpQsmrwGB": "main-nowPlayingView-coverArtContainer", "l2PpoXJouAgqFCuNT3iB": "main-nowPlayingView-credits", "G6WmxixPKmCYMNxmUNPT": "main-nowPlayingView-credits", "g5zF2gHZOarew6ApvZB6": "main-nowPlayingView-creditsGroup", "kUyRPckYBgHDaJp8bmXi": "main-nowPlayingView-creditsHeader", "bBldZtWu4QtzmrTfHOKm": "main-nowPlayingView-creditsShowAll", "PqjIyA05rhDaDg2S1qIQ": "main-nowPlayingView-creditsSource", "MPBLLykSgRJIlLSbQVgy": "main-nowPlayingView-gradient", "SfAYznqZyNk_AvvxIkUx": "main-nowPlayingView-header", "hzUuLPdH48AzgQun5NYQ": "main-nowPlayingView-lyricsContent", "KzMnBC9eFK8cAfcFTg9b": "main-nowPlayingView-lyricsControls", "I2WIloMMjsBeMaIS8H3v": "main-nowPlayingView-lyricsGradient", "N9Xjnxz8vGgWwbEBE5g7": "main-nowPlayingView-lyricsTitle", "wpJvLvrrnyP0_C7hLkqg": "main-nowPlayingView-merch", "n4_WcnoVeg2SeDJPWnKK": "main-nowPlayingView-nextInQueue", "cIUedsmg_cTnTxvOYTKR": "main-nowPlayingView-nowPlayingGrid", "XHoCGTR6RVHuq2o36icg": "main-nowPlayingView-nowPlayingGrid", "fIpDXK7M3W0Bn3FgLSRe": "main-nowPlayingView-nowPlayingWidget", "ehLi5oxIfUbTLk2NPPB4": "main-nowPlayingView-nowPlayingWidget", "PqL625rkFi7CBiMggYTP": "main-nowPlayingView-openQueue", "wkl1CJw1cTKpqlKDAiln": "main-nowPlayingView-panelOpenDiv", "byIN5OSjNcJHipcI9kuf": "main-nowPlayingView-playNext", "LT6lpp3S4Tx8VsbnPzuA": "main-nowPlayingView-playNextButton", "A4e013b7hUST0QPHFqKr": "main-nowPlayingView-playNextButtonIcon", "V6Fgup3wwQdhZVYVntrH": "main-nowPlayingView-playNextIcon", "qbOrWcMUhSri1nPkZLQA": "main-nowPlayingView-queue", "ccC6ZoV_TBWVmq_wdgzc": "main-nowPlayingView-queue", "Mj718TwbPAUi_iNAcsmz": "main-nowPlayingView-queueItem", "Nyxk_izrYbGecpgmtp91": "main-nowPlayingView-queueItemEntityImage", "Ai_McRq9wJEYK21w8nX_": "main-nowPlayingView-section", "gXpVKubH7jLZ4sQ2CUBn": "main-nowPlayingView-section", "EVqc6HChiM9pEqBYAiUE": "main-nowPlayingView-sectionHeader", "mdEMa1ZY6qt201KkKL0F": "main-nowPlayingView-sectionHeader", "gpDSOimnzH4zTJmE7UR5": "main-nowPlayingView-sectionHeaderSpacing", "zZdI03asKaUCNlbhjDAv": "main-nowPlayingView-sectionHeaderText", "BInqOrncCcclcBp4uBnY": "main-nowPlayingView-sectionHeaderText", "hfdkySA4kiUldFsPj9lD": "main-nowPlayingView-trackInfo", "fjCyBC5HnIDoDPrpbbv8": "main-nowPlayingView-trackInfo", "BFR9Zt3zpL8BATBMiwQB": "main-nowPlayingWidget-coverArt", "vBMZGgINJ_BbsQM82LhK": "main-nowPlayingWidget-coverArt", "bYHWD_eQ1jAh3sAKTHtr": "main-nowPlayingWidget-coverExpanded", "deomraqfhIAoSB3SgXpu": "main-nowPlayingWidget-nowPlaying", "OhVah4L2N7DWZ8VnPhea": "main-nowPlayingWidget-nowPlaying", "j96cpCtZAIdqxcDrYHPI": "main-nowPlayingWidget-trackInfo", "V98keH9h9rnepsa_qe_r": "main-nowPlayingWidget-trackInfo", "fFv7yCuLuIO1dAGZHcVf": "main-pageErrorTemplate-errorBody", "fDD5IxaW7WW8LZTlwzs4": "main-playbackBarRemainingTime-container", "npFSJSO1wsu3mEEGb5bh": "main-playbackBarRemainingTime-container", "kQqIrFPM5PjMWb5qUS56": "main-playbackBarRemainingTime-container", "f4XfIkH9v3tBTnI8AEDj": "main-playButton-lockIcon", "IeLnf2wUHVKqxhzBcBoM": "main-playButton-PlayButton", "PFgcCoJSWC3KjhZxHDYH": "main-playButton-PlayButton", "ix_8kg3iUb9VS5SmTnBY": "main-playButton-PlayButton", "vIRREgHGvNoc_fRsISih": "main-playButton-PlayButton", "KOoUMuC7IxI_1Pi4r4m5": "main-playButton-primary", "VgweZbpfbMSfOcTeGNj_": "main-playButton-secondary", "D7HQ50jRBGU8qaAGHRTw": "main-playButton-transparent", "ke5Pf1zkbk0eGnRWZYyg": "main-playlistEditDetailsModal-albumCover", "jBtrrjZB6NApIt7dtMQD": "main-playlistEditDetailsModal-albumCover", "CMQlNrl4E4TNHVowBoZ9": "main-playlistEditDetailsModal-characterCounter", "SHNkS_d5PbgJ6CgcYaUk": "main-playlistEditDetailsModal-characterCounter", "MQQEonum615k8mGkliT_": "main-playlistEditDetailsModal-closeBtn", "qNP5_KI5WgVdKUUrdOk6": "main-playlistEditDetailsModal-closeBtn", "PiyAiXdQULEnWAHP0tu1": "main-playlistEditDetailsModal-container", "UJRUb6VMsdk5NZboCmg4": "main-playlistEditDetailsModal-container", "CU0wnmWejIvyEsRRtSac": "main-playlistEditDetailsModal-content", "fIsU1JbqyjMITbYOgJph": "main-playlistEditDetailsModal-content", "_qRr3jb13hOiy1EvSL_r": "main-playlistEditDetailsModal-description", "aeeOEB8Rw3sgCRiOmHik": "main-playlistEditDetailsModal-description", "lXzpMHpJSt7uRB1DXwen": "main-playlistEditDetailsModal-descriptionCharacterCounter", "hrJ75E6d_5JE_s_w75dA": "main-playlistEditDetailsModal-descriptionCharacterCounter", "c0CddR8wF7kDxvU6uM8B": "main-playlistEditDetailsModal-descriptionTextarea", "s5cUBUk0EfYa2xMxSSPD": "main-playlistEditDetailsModal-descriptionTextarea", "B_4ndHDFJnCj4dxQmvKA": "main-playlistEditDetailsModal-descriptionTextareaWithPadding", "g4NoSm4nlTuArQvfAaDa": "main-playlistEditDetailsModal-disclaimer", "krfRqxdeDAttst7COcD0": "main-playlistEditDetailsModal-disclaimer", "R2w_sH83CJU9Yhnu0xyt": "main-playlistEditDetailsModal-header", "u91SehceIJM9fyoWEn3Q": "main-playlistEditDetailsModal-header", "UxrKbK6rUr4vmo6SmCab": "main-playlistEditDetailsModal-imageChangeButton", "ZKDvF1q4yO4yUkfI0rFS": "main-playlistEditDetailsModal-imageChangeButton", "mtGn3ylACuEcnBDCuR2g": "main-playlistEditDetailsModal-imageDropDownButton", "Y7_Q_VL5y7FKVYzILZlR": "main-playlistEditDetailsModal-imageDropDownButton", "i2x0uFyIEg3YN6njOngZ": "main-playlistEditDetailsModal-imageDropDownContainer", "hF2x3Nk34N0RVwSOfRqu": "main-playlistEditDetailsModal-imageDropDownContainer", "St_O5qpuAv8_Rt8xLueG": "main-playlistEditDetailsModal-imageLoadingContainer", "_rBnbQXOUaS8hxF05Vvj": "main-playlistEditDetailsModal-insertLinkButton", "Up_Ke_BKTraatSMY_Po_": "main-playlistEditDetailsModal-save", "EMwLV6xnTcOVTKEV2vaK": "main-playlistEditDetailsModal-save", "KHbA1pftwmeRxPgRj6XW": "main-playlistEditDetailsModal-sectionsContainer", "f0GjZQZc4c_bKpqdyKbq": "main-playlistEditDetailsModal-textElement", "CPK2Xj5o66p6ipEmsVqZ": "main-playlistEditDetailsModal-textElement", "UCj7uEr7vR_0DO3cQHcX": "main-playlistEditDetailsModal-textElementError", "JqI3LI5Df0M4YNOvUSCw": "main-playlistEditDetailsModal-textElementInfo", "rosHlzYfiO0UfpmOhP4I": "main-playlistEditDetailsModal-textElementLabel", "LibRpovtQwu0kbIGh9vK": "main-playlistEditDetailsModal-textElementLabel", "RLzMolC7kIdp65LyfQPb": "main-playlistEditDetailsModal-title", "HutS10br7QppJJ5_XHya": "main-playlistEditDetailsModal-title", "qOBo9jzgJoMkt2Ad8fur": "main-playlistEditDetailsModal-titleCharacterCounter", "ZfCG8e1oAWX1FroDbeFF": "main-playlistEditDetailsModal-titleCharacterCounter", "JaGLdeBa2UaUMBT44vqI": "main-playlistEditDetailsModal-titleInput", "ZSbwUhw8fvOgwrnjZSbv": "main-playlistEditDetailsModal-titleInput", "SuHDp5IvWoCq1P8yJQmo": "main-playlistEditDetailsModal-titleInputWithPadding", "AytCc2WKUld6N212Pcpu": "main-playlistRemoveMultipleModal-buttonContainer", "J0xJcBaKhwl9EIuzvhLg": "main-playlistRemoveMultipleModal-container", "tlBLfMv0fCxd31jPTQhL": "main-playlistRemoveMultipleModal-description", "ce3qMCnc2kDVSi7k74fh": "main-playlistRemoveMultipleModal-title", "vnCew8qzJq3cVGlYFXRI": "main-playPauseButton-button", "QavgDs_52SpJ2rw0LNYz": "main-popper-arrow", "aCtCKL9BxAoHeVZS0uRs": "main-popper-container", "kMVnZekiwoVgg0Vjd418": "main-progressRing-circleColor", "PcjBXoPwLAnvPiTQx79g": "main-progressRing-circleShadow", "tP0mccyU1WAa7I9PevC1": "main-repeatButton-active", "Vz6yjzttS0YlLcwrkoUR": "main-repeatButton-button", "qnYVzttodnzg9WdrVQ1p": "main-rootlist-bottomSentinel", "xkHiFJhykpOWcVMMPufq": "main-rootlist-dropIndicator", "I_aApN9pSlbGcpLtFQWw": "main-rootlist-expandArrow", "NTJM_mh36C5kJ5oO8eac": "main-rootlist-expandArrowActive", "LKgm9fCDTO7wqig_5U1q": "main-rootlist-rootlist", "EY6S7vlkxB7SF_OjjF_Y": "main-rootlist-rootlistContent", "FBPrcmuqo3yv5UfWSRl5": "main-rootlist-rootlistDivider", "McwcCfBLSuXa5UDU1IMw": "main-rootlist-rootlistDividerContainer", "Y8edH1Yjo4xrW_58czQj": "main-rootlist-rootlistDividerGradient", "whXv9jYuEgS1DPTmPCe_": "main-rootlist-rootlistItem", "utSR0FVkHnII_aL8TOcu": "main-rootlist-rootlistItemLink", "K8Rs3qAYirS8wJ1hR8gn": "main-rootlist-rootlistItemLinkActive", "AINMAUImkAYJd4ertQxy": "main-rootlist-rootlistItemOverlay", "tojGvx6tcIBmKlICMJAZ": "main-rootlist-rootlistPlaylistsScrollNode", "g_jOSq3pLY5p4tldskrw": "main-rootlist-statusIcons", "gtuJjD43VjwtP8ii3H3P": "main-rootlist-statusIcons", "VjIb8SfYTkc4wMpqqj3f": "main-rootlist-textWrapper", "lyVkg68L7ycnwyOcO3vj": "main-rootlist-topSentinel", "PVcM2tzEirZrUPGhsuaQ": "main-rootlist-topSentinel", "JUa6JJNj7R_Y3i4P8YUX": "main-rootlist-wrapper", "DeV0zPbzpbB31xcBc9gz": "main-rootlist-wrapper", "rKdWluhLAGpdUFBWN8sK": "main-seeAll-link", "MRfNcNMd_djj3KOg_VOB": "main-seekBackButton-button", "XGCdw_LcXQHrxmvpxjwi": "main-seekForwardButton-button", "r4Qmv_YM7IHogP2i1tmJ": "main-shelf-browseGridSection", "q8AZzDc_1BumBHZg0tZb": "main-shelf-header", "HOgue4Eg4UdBR58M0633": "main-shelf-seeAll", "QyANtc_r7ff_tqrf5Bvc": "main-shelf-shelf", "HVBIEiiVvehha3rRJA78": "main-shelf-shelf", "Z4InHgCs2uhk0MU93y_a": "main-shelf-shelfGrid", "KMMqgUnvRAqSSii6zn7L": "main-shelf-shelfGrid", "Sdmk6QLCvk5EuAP54IN5": "main-shelf-shelfGridResponsive", "PodseQtNc4pWHyOfGkgA": "main-shelf-shelfGridResponsiveMultiRows", "BtbiwMynlB4flsYu_hA2": "main-shelf-showAll", "WkSW1U6jr3HDz3vApM56": "main-shelf-skeletonShelf", "hWGxHSAKACFWXowXPDTP": "main-shelf-subHeader", "MfVrtIzQJ7iZXfRWg6eM": "main-shelf-title", "onVWL7MW4PW9FyVajBAc": "main-shelf-titleWrapper", "OMuRYOdpUbGif12_lRJl": "main-shelf-topRow", "OF_3F0SQCsBtL1jSTlTA": "main-shuffleButton-active", "KVKoQ3u4JpKTvSSFtd6J": "main-shuffleButton-button", "fn72ari9aEmKo4JcwteT": "main-skipBackButton-button", "mnipjT4SLDMgwiDCEnRC": "main-skipForwardButton-button", "d89qxCxbCRj4y1Woft8j": "main-smartShuffleButton-active", "kpGMQq1KFz620g_BD_dS": "main-tag-container", "T1xI1RTSFU7Wu94UuvE6": "main-topBar-background", "PfgTAe4hVhuNFZRuuKQG": "main-topBar-background", "IAyWaeDamLJLjxuPeVKw": "main-topBar-buddyFeed", "WtC1lGbmQRplD6JBhNFU": "main-topBar-buddyFeed", "pefa1_ZALRn90eYLANvw": "main-topBar-buddyFeed", "W0bXxvPV_DhyzwdJWRuU": "main-topBar-buddyFeedActive", "CE17OcVsW2svuK7ebBcx": "main-topBar-buddyFeedIcon", "ql0zZd7giPXSnPg75NR0": "main-topBar-button", "J6VTd7VdGN2PM_oXCAyH": "main-topBar-button", "qvXMfQh1CjESoKKX49Bl": "main-topBar-buttonActive", "facDIsOQo9q7kiWc4jSg": "main-topBar-container", "VCH3TJP5s27cQwnxWKnA": "main-topBar-container", "MIX_wd0K1tVHme_pwV2F": "main-topBar-contentArea", "BkpKedcdaMGbvgXMlmcg": "main-topBar-contentArea", "OOsg_GCQDERDXc1d0EmC": "main-topBar-entryPoints", "pfMoD1MbelMuF1m8QeMc": "main-topBar-forward", "Oq5wiHCwgjjMg_VYy068": "main-topBar-fullscreenHistoryButtons", "VgSbatGBB9XwTH2_dsxg": "main-topBar-historyButtons", "fWwn9sakqBBjgiNti7LD": "main-topBar-historyButtons", "IYDlXmBmmUKHveMzIPCF": "main-topBar-icon", "ou0osOf3R1ZRWQ1xzFd9": "main-topBar-icon", "cqO5c3gPyN6tXIddpWfr": "main-topBar-left", "qxbaGYC8rgMLfyOuYRCM": "main-topBar-loggedOut", "LKFFk88SIRC9QKKUWR5u": "main-topBar-loginButtons", "zuf9gCpkf86KkzzCtkJN": "main-topBar-navLink", "EvIR4O7jOSbNmxtMdIQ0": "main-topBar-overlay", "fKbzlgPXfvClLUUKYLxj": "main-topBar-overlay", "CWFTCu03cdDALodezHmA": "main-topBar-responsiveForward", "fl1Ov5aB9YCKnMkJYpEu": "main-topBar-right", "VGKBvschhennrwdTibUE": "main-topBar-rightSidebarVisible", "g3Xinb8x23n81ejvS9Uj": "main-topBar-searchBar", "CVuGEUIxLkNKpMds8AFS": "main-topBar-searchBar", "CFfhVwF52u3lETJ8lRWE": "main-topBar-searchBar", "btOYheZlYlaVEyO9iEBk": "main-topBar-sectionWrapper", "sibxBMlr_oxWTfBrEz2G": "main-topBar-signupButton", "eBrbJuUWgMoCkOgWs5uw": "main-topBar-topBarContainer", "qHWqOt_TYlFxiF0Dm2fD": "main-topBar-topbarContent", "SgtTbe7qcK0Rae6rXLWm": "main-topBar-topbarContent", "rwdnt1SmeRC_lhLVfIzg": "main-topBar-topbarContentRight", "VHvOWFJZz7l5Py6Vud3A": "main-topBar-topbarContentRight", "CuBx12mEGmMQ1XAXHZCs": "main-topBar-topbarContentRight", "rovbQsmAS_mwvpKHaVhQ": "main-topBar-topbarContentWrapper", "QoAE5SWKbvYt9ogonr6e": "main-topBar-topbarContentWrapper", "sNde2kloCY28V4GB9AvP": "main-topBar-topNavBarLinks", "k0vXhOdr0XE83lAQaJ1O": "main-topBar-topNavBarSeparator", "Upqw01TOXETOmR5Td7Dj": "main-topBar-UpgradeButton", "rqZoelG5u2vHb3kedEph": "main-topBar-UpgradeButton", "I4p8r1UNjIGk9yv3H2Ms": "main-topBar-whatsNewFeed", "t794tYAiOJib_IAmmdqq": "main-topBar-whatsNewFeedActive", "t93PZphItuM19kPhX7tC": "main-topBar-whatsNewFeedIcon", "RJGA3d0jzObTXwoMUH4p": "main-topBar-whatsNewFeedNotification", "kwTbLplLLsW6T_LrtR7_": "main-topBar-whatsNewFeedNotificationDot", "dmF7or26BvPJs5kQtYVH": "main-topBar-whatsNewFeedNotificationTitle", "coBkWVskipFo8KxLKief": "main-topBar-withBackgroundBlur", "g_OLJCea3ISA_OEZXMld": "main-topBarStatusIndicator-enter", "eUtkhxZmvSKe4G9vJbvG": "main-topBarStatusIndicator-enterActive", "y0CwI5JkA0h0OyLFf53Q": "main-topBarStatusIndicator-exitActive", "uObDRTsYYQmLhK7QzuFF": "main-topBarStatusIndicator-hasTooltip", "JbIx9RVHwxVXRbXAJaeN": "main-topBarStatusIndicator-notMinimized", "FannkPV_e2vWawF9QgVA": "main-topBarStatusIndicator-tooltipEnter", "CtNz_OQHOZ5Z0mJMHjzw": "main-topBarStatusIndicator-tooltipEnterActive", "rSvmoStBWe2Cft5bw9ya": "main-topBarStatusIndicator-tooltipIsError", "Y22srYwXssaWnfD1MXis": "main-topBarStatusIndicator-TopBarStatusIndicatorContainer", "mqZLFQb6fadgKQww5AFV": "main-trackCreditsModal-additionalCredits", "HJGB9Fo4Xs8h5BkBBeDu": "main-trackCreditsModal-additionalCredits", "xd9f6OsPqmyb6CefhTRR": "main-trackCreditsModal-clickableCreditsEntry", "XrHz4A9uD7ZGIKQsSyNQ": "main-trackCreditsModal-clickableCreditsEntry", "VKCcyYujazVPj6VkksPM": "main-trackCreditsModal-closeBtn", "QQ13a8MJiuYLu0q6rZ2A": "main-trackCreditsModal-closeBtn", "uV8q95GGAb2VDtL3gpYa": "main-trackCreditsModal-container", "KslWdQXWjQsaid2M3oM8": "main-trackCreditsModal-container", "IpshWHA6nc9nJxRssAlb": "main-trackCreditsModal-creditsEntry", "nv9qp4kKPjm8xaSYV1Of": "main-trackCreditsModal-creditsEntry", "beyOcd3p0PEzhrlKIbU1": "main-trackCreditsModal-creditsGroup", "PK1NwDda3K9m_l1F57mQ": "main-trackCreditsModal-creditsGroup", "pGU_qEtNT1qWKjrRbvan": "main-trackCreditsModal-header", "QsEMdNpWb7BvcopiZBY7": "main-trackCreditsModal-header", "Nw1INlIyra3LT1JjvoqH": "main-trackCreditsModal-mainSection", "VEh7w_UmdiXh8yRLKVmB": "main-trackCreditsModal-mainSection", "iGT1RlMPCwUlIiRPbOqg": "main-trackCreditsModal-originalCredits", "caPUJnD8oMW_y3rXBllV": "main-trackCreditsModal-originalCredits", "bpaf11XBuMN_hd95L9ol": "main-trackCreditsModal-sectionTitle", "syZlEQP36Qn7I50V6d4N": "main-trackCreditsModal-sectionTitle", "DAIH23Yj6NeioZ6jooQ6": "main-trackCreditsModal-sourceNames", "vK_WgBGfaNGiJjeNvajh": "main-trackCreditsModal-sourceNames", "gpNta6i8q3KYJC6WBZQC": "main-trackInfo-artists", "w_TTPh4y9H1YD6UrTMHa": "main-trackInfo-artists", "QKm_g41pGRkjUcr_2cIM": "main-trackInfo-artists", "ZcNcu7WZgOAz_Mkcoff3": "main-trackInfo-container", "iZrIHsls0lCEhoMDA9kc": "main-trackInfo-container", "QZfVz1fBRtwZIIrMKL6v": "main-trackInfo-container", "W5cB_o0XkkU7Q8tlTGxq": "main-trackInfo-contentContainer", "cpltqpeZsQmmXy7qZgb9": "main-trackInfo-contentContainer", "IkAuhlmCofCxyvdphgkv": "main-trackInfo-contentWrapper", "PGSe59fD1Hwc9yUM2d3U": "main-trackInfo-contentWrapper", "upgEerbOWZz66oZhE5G5": "main-trackInfo-contentWrapper", "s1jyNJBxq16eqkqCf6Ax": "main-trackInfo-enhanced", "p2ya1fQ3o9pY4alcW0o4": "main-trackInfo-enhanced", "LDY4KpSvSSGHG6jRU5QA": "main-trackInfo-enhanced", "y6bXimbi8JAcblOFxTap": "main-trackInfo-equalGradientWidth", "Q_174taY6n64ZGC3GsKj": "main-trackInfo-name", "l3ePjQ6SwNdQQCnLpywl": "main-trackInfo-name", "PcH6VnzkkDqD36P93i9Q": "main-trackInfo-name", "K9Nj3oI7bTNFh5AGp5GA": "main-trackInfo-name", "e_FyYW4DtJAcPdudjghs": "main-trackInfo-name", "FYny4fSXzbXG65hAD1Pn": "main-trackInfo-overlay", "eSMjmiD29Ox35O95waw6": "main-trackInfo-overlay", "ztTFEVvpDUywSVxJw4t7": "main-trackInfo-overlay", "aMhBfOqJxn4jKzFN07Lg": "main-trackInfo-rightToLeft", "Ty1q4GZz5EJ7Yl2jHs5I": "main-trackInfo-xsmallBadges", "MrkH0O1OzmNv_oCQdvI8": "main-trackInfo-xsmallBadges", "lvEvs7EuvpagAgPzLl_B": "main-trackInfo-xsmallBadges", "Ry7zokVNFKXaDxKp1Qf1": "main-trackList-actionsHeader", "iSbqnFdjb1SuyJ3uWydl": "main-trackList-active", "ZgAJecvDDVREPXktThbA": "main-trackList-active", "eWNJl03RSvGa9VsxkJ_7": "main-trackList-addedBy", "zxSdNpIMNoVh8g3F9fqt": "main-trackList-arrow", "wE9dp6W8uInhrlbWPMsR": "main-trackList-chartTrackList", "ASYv4mEu1lXEHVa04HqY": "main-trackList-column", "rGujAXjCLKEd_N6yTwds": "main-trackList-column", "Q3gtrgbIqUuVTw9350Us": "main-trackList-column", "r53kBgGOdrCYj2Jux0iN": "main-trackList-columnResizing", "Wn5NpEuR1Qw99DelnfxX": "main-trackList-concertTrackList", "KJeaWI3jCZemsmu4eYL3": "main-trackList-curationButton", "otqy2yIt_BVXLjoundpp": "main-trackList-curationButton", "SzsIJoBzlexhelPsHXnn": "main-trackList-delayedVisibility", "Ar1CZ7qjPHuIJY0cI56W": "main-trackList-disabled", "Unhd72dSzMriFEuvX2UU": "main-trackList-disabled", "UuAaE00MS64uibJyxXxK": "main-trackList-discRow", "xmIMj8Eo8ZixOkuRQWd3": "main-trackList-discTitle", "WTbn2dLWM9fMMb3O4uKZ": "main-trackList-dropTargetAfter", "xhgesf8qPsbyZY2NaYdH": "main-trackList-dropTargetBefore", "AgiCqnZUliKs_dafpdyi": "main-trackList-durationHeader", "kxxyFjKz2levImEvxq48": "main-trackList-durationHeader", "qcny9ih7ulue1dEOh48T": "main-trackList-durationHeader", "m6Bvw7PNXw2WXqruLOLE": "main-trackList-enhanced", "vr04lRmH66LzICUXnwCq": "main-trackList-eventDate", "OINF2mTM4cu2JjtkmPiz": "main-trackList-eventTicketIcon", "vL_PgycspyjCBwfFg9j9": "main-trackList-eventVenue", "dZiDwJihz32GwQunXhnB": "main-trackList-facepile", "uWYVhrrTnV2V_VYhThDl": "main-trackList-facepileAvatar", "FCzIz5e0Lpt4xa5zz2F1": "main-trackList-icon", "xEtoX9iOYS58uDtKLmzR": "main-trackList-icon", "vBFTtFW3Co9F_yJ_HjF4": "main-trackList-icon", "Ss6hr6HYpN4wjHJ9GHmi": "main-trackList-indexable", "oYS_3GP9pvVjqbFlh9tq": "main-trackList-indexable", "itxBsCHKFwTOvmERT1cg": "main-trackList-indexable", "Kb365Ykr7fUEvnvOH0vl": "main-trackList-isNextRowSelected", "dXWJ1DecZeg_dpXZUbHL": "main-trackList-isPreviousRowSelected", "vDk3w0iWhhczk8PS_K3B": "main-trackList-isRecommendedTrackListRow", "jDgf8MzZRbApYE6BW1qL": "main-trackList-isRecommendedTrackListRow", "HOP1JqKm27djuzPVbaRl": "main-trackList-nineteen", "w304euOUWkI5A8qAqFj8": "main-trackList-notificationDot", "VrRwdIZO0sRX1lsWxJBe": "main-trackList-number", "xNyTkXEncSjszLNI65Nq": "main-trackList-number", "YCOwZBWtN6TZawPOEVI9": "main-trackList-number", "nEZjuVeUuGSmYaQWgXTd": "main-trackList-ownedBySelf", "z0zJ798TVq97lZgdRT2_": "main-trackList-ownedBySelf", "Iy7vi9cVsy6pS6wF8Dud": "main-trackList-placeholder", "Dj9_CzXA7IbUFIz4wOsA": "main-trackList-placeholderEnd", "cF8vKdBGYRZs_SpaG0Yz": "main-trackList-placeholderIndex", "J0lnyV5H9q4Jj7FK0A72": "main-trackList-placeholderStart", "BciIfT5b6BSIPIr6feK4": "main-trackList-placeholderStartWithCoverImage", "noANc1fQSmaQNKYVDEDQ": "main-trackList-placeholderVariable", "n5XwsUqagSoVk8oMiw1x": "main-trackList-playingIcon", "DZJJ5SCypi2mREbjy5bx": "main-trackList-playingIcon", "TYVy_QqiFWgzw0WkUiHb": "main-trackList-playsHeader", "s6jK2TtsvpysB_LjjS7g": "main-trackList-playsHeader", "B2Dwok3Y07k2ttjGDssH": "main-trackList-queuePanelTracklist", "Bob5Qz4qd2ApsH6o1loA": "main-trackList-resizeHandle", "sQcIERaiZKFhOM1LrSmX": "main-trackList-rowBadges", "_7_yPy5jfb9kzk3gijq6A": "main-trackList-rowBadges", "UeRAfdSphrPaohh2HWmB": "main-trackList-rowCompactMode", "N7GZp8IuWPJvCPz_7dOg": "main-trackList-rowCompactMode", "Btg2qHSuepFGBG6X0yEN": "main-trackList-rowDuration", "l5CmSxiQaap8rWOOpEpk": "main-trackList-rowDuration", "bnolo3jJ8KBxI6jyN7bD": "main-trackList-rowFeedback", "U_mTGq4vzVyOrPrB3mx4": "main-trackList-rowFeedbackButton", "tGKwoPuvNBNK3TzCS5OH": "main-trackList-rowHeartButton", "GcODM2Bp3srQqJzi8Tzs": "main-trackList-rowHeartButton", "rkw8BWQi3miXqtlJhKg0": "main-trackList-rowImage", "IqDKYprOtD_EJR1WClPv": "main-trackList-rowImage", "GTdNqPsL1mHfybwJSeVz": "main-trackList-rowImage", "EfStVlHpnUDOJF3pM93I": "main-trackList-rowImageFallback", "tgCyNnKttOMQXfuqVuhI": "main-trackList-rowImageFallback", "RfidWIoz8FON2WhFoItU": "main-trackList-rowImagePlayButton", "j2s64Lz8y6VzBLB_V9Gm": "main-trackList-rowImagePlayButton", "y3wrMu2sPRR2DCdEpWlg": "main-trackList-rowImagePlayButton", "Qs11Fsr_XqTVFDFWWRkQ": "main-trackList-rowImagePlayPauseButton", "OXMPsUBpIQoIbOPIv7Bh": "main-trackList-rowImagePlayPauseButton", "DoIH4Mjt4sJFHkmAGs03": "main-trackList-rowImagePlayPauseButtonPlaying", "cxYjUh_DjUkRijOXGrOT": "main-trackList-rowImageVideo", "byLkljnIRd_DJeSMD3LM": "main-trackList-rowImageWithPlay", "gmuBAqsC6pcufUyP1VQW": "main-trackList-rowImageWithPlay", "iCQtmPqY0QvkumAOuCjr": "main-trackList-rowMainContent", "_iQpvk1c9OgRAc8KRTlH": "main-trackList-rowMainContent", "hb8C1VAjyUg0VMxrwpix": "main-trackList-rowMainContent", "gQnx5tArze5q1wfG6AWJ": "main-trackList-rowMainContentCompact", "ft6dUifK4i03829TBAqC": "main-trackList-rowMainContentCompact", "VpYFchIiPg3tPhBGyynT": "main-trackList-rowMarker", "ucB9avGYvzsmzXUOw0S7": "main-trackList-rowMarker", "UUe_W_6spimNc8vpdEEO": "main-trackList-rowMarker", "JxZLQbpnH3fFGJHB4XQG": "main-trackList-rowMarkerChartStatus", "t4yFt9Ch_ZCPxEEEoImE": "main-trackList-rowMarkerChartStatus", "mYN_ST1TsDdC6q1k1_xs": "main-trackList-rowMoreButton", "ObVor_8sQq5whKbtWs8a": "main-trackList-rowMoreButton", "nYg_xsOVmrVE_8qk1GCW": "main-trackList-rowPlayCount", "HxDMwNr5oCxTOyqt85gi": "main-trackList-rowPlayCount", "UIBT7E6ZYMcSDl1KL62g": "main-trackList-rowPlayPauseIcon", "zOsKPnD_9x3KJqQCSmAq": "main-trackList-rowPlayPauseIcon", "HcMOFLaukKJdK5LfdHh0": "main-trackList-rowSectionEnd", "PAqIqZXvse_3h6sDVxU0": "main-trackList-rowSectionEnd", "qszimzwbM7AdyZGTvofd": "main-trackList-rowSectionEnd", "NZAU7CsuZsMeMQB8zYUu": "main-trackList-rowSectionIndex", "fS0C4IgbHviZxIVGC736": "main-trackList-rowSectionIndex", "ZC9Da494kc24m9FG64Pf": "main-trackList-rowSectionIndex", "gvLrgQXBFVW6m9MscfFA": "main-trackList-rowSectionStart", "w46g_LQVSLE9xK399VYf": "main-trackList-rowSectionStart", "J_3tQnLWkbEcYffbaUL4": "main-trackList-rowSectionStart", "bfQ2S9bMXr_kJjqEfcwA": "main-trackList-rowSectionVariable", "_TH6YAXEzJtzSxhkGSqu": "main-trackList-rowSectionVariable", "qev2KFBSKCHkeXT4fDTl": "main-trackList-rowSectionVariable", "rq2VQ5mb9SDAFWbBIUIn": "main-trackList-rowSubTitle", "t_yrXoUO3qGsJS4Y6iXX": "main-trackList-rowTitle", "btE2c3IKaOXZ4VNAb8WQ": "main-trackList-rowTitle", "eRuZMo_HNLjb1IalIeRb": "main-trackList-selected", "JgERXNoqNav5zOHiZGfG": "main-trackList-selected", "FCqh1RprhBCx2nZeC2Xi": "main-trackList-showDisabledAsEnabled", "ZdBognHQ3X610bLWE3e3": "main-trackList-sortable", "Bh8ehD8at2hrINB7YMOg": "main-trackList-sortable", "blGAYwvDWndVkFDAQZ8A": "main-trackList-sortable", "vY_4na7XFQWMFH8phXCQ": "main-trackList-statusChangeDown", "NJMsWXHYQgISlxnPODAD": "main-trackList-statusChangeDown", "YAINlTb90ZejTPv7k1dH": "main-trackList-statusChangeNew", "OqarR9DPk9OgwzSAwR05": "main-trackList-statusChangeNew", "zbBdn49lgTKVccLVZBqE": "main-trackList-statusChangeUp", "VrcWCORvyjWWMoz4Mbpd": "main-trackList-statusChangeUp", "_3IwXr7oR_KZlfPfwMh7": "main-trackList-talkIcon", "y8YIxGr73OXUGacKKsWb": "main-trackList-talkSegmentDuration", "jsV182e49Puwz9SIYt2J": "main-trackList-text", "ShMHCGsT93epRGdxJp2w": "main-trackList-trackList", "oIeuP60w1eYpFaXESRSg": "main-trackList-trackList", "V3hMbl6JubddBUkF2XKw": "main-trackList-trackList", "ixZyJJ3SHxsSb3NHkhWn": "main-trackList-trackListCompactMode", "iHiqmESWdGRBwk5cS7ZZ": "main-trackList-trackListCompactMode", "koyeY6AgGRPmyPITi7yO": "main-trackList-trackListHeader", "IpXjqI9ouS_N5zi0WM88": "main-trackList-trackListHeader", "Y4EDvZXtKfdzwuoUAPwO": "main-trackList-trackListHeader", "dZPmmYYhskhqHJCAruvI": "main-trackList-trackListHeaderRow", "ePPpO_NuGDUxVRTw7y6W": "main-trackList-trackListHeaderRow", "U9A7_SGWn6IJ0vAM2oU7": "main-trackList-trackListHeaderRow", "qJOhHoRcFhHJpEQ2CwFT": "main-trackList-trackListHeaderStuck", "_2ajKWDiy6YvJu5wo8I1g": "main-trackList-trackListHeaderStuck", "h4HgbO_Uu1JYg5UGANeQ": "main-trackList-trackListRow", "IjYxRc5luMiDPhKhZVUH": "main-trackList-trackListRow", "UhOLa3blz2xoAxM2vRwz": "main-trackList-trackListRow", "wTUruPetkKdWAR1dd6w4": "main-trackList-trackListRowGrid", "UpiE7J6vPrJIa59qxts4": "main-trackList-trackListRowGrid", "vzvX6wzymW8rwI4hkYo0": "main-trackList-trackListRowGrid", "vOp2HlcPkxOHebo3If32": "main-useDropTarget-album", "O0AN8Ty_Cxd4iLwyKATB": "main-useDropTarget-album", "G_xEAccmp3ulqXjuviWK": "main-useDropTarget-album", "VNdHKKznHkpJ0VHoDmai": "main-useDropTarget-artist", "wQnUXn1m6Gy4PH8jhslb": "main-useDropTarget-artist", "zWWLnqWslTLHwq3wBgGB": "main-useDropTarget-audiobook", "ufICQKJq0XJE5iiIsZfj": "main-useDropTarget-base", "NxEINIJHGytq4gF1r2N1": "main-useDropTarget-base", "LLlfyKiKbOd8gfCmHcZX": "main-useDropTarget-base", "ETclQEbcAcQdGdSioHaJ": "main-useDropTarget-episode", "XNjgtSbyhshr7YQcVvry": "main-useDropTarget-episode", "LNzflW6HN3b7upl8Pt7w": "main-useDropTarget-episode", "cuH8l_VHkTiz_NYVslQe": "main-useDropTarget-folder", "mhuhir0ikRqXAPHU8ZZ1": "main-useDropTarget-folder", "aRyoyQFJkzhoSOnf2ERM": "main-useDropTarget-local", "odS2IW9wfNVHhkhc0l_X": "main-useDropTarget-local", "FQFIqbs9Ic3VDNohmxRp": "main-useDropTarget-local", "kXEKypZEUzxx9rNJy09C": "main-useDropTarget-playlist", "D8wJ9TPfJzLeLJYxnad2": "main-useDropTarget-playlist", "eZnAGhYcXE4Bt0a7958z": "main-useDropTarget-playlistV2", "pTvxY5yAQklZgb7VZFGS": "main-useDropTarget-pseudoPlaylist", "ratGUXdpLCkyXZNaJryg": "main-useDropTarget-show", "oE8LAmRhbeQqsZrQo4lb": "main-useDropTarget-show", "caTDfb6Oj7a5_8jBLUSo": "main-useDropTarget-track", "or84FBarW2zQhXfB9VFb": "main-useDropTarget-track", "HgSl1rNhQllYYZneaYji": "main-useDropTarget-track", "atsRVFhRDxRbOyXyFZjS": "main-userWidget-active", "odcjv30UQnjaTv4sylc0": "main-userWidget-box", "KAq2kDjXj2VS4eXrFL4i": "main-userWidget-box", "BsYNRaiIo2R6htfnZiuG": "main-userWidget-box", "Fxnb0xe6bL7I7W8V0p6C": "main-userWidget-boxCondensed", "SFgYidQmrqrFEVh65Zrg": "main-userWidget-boxCondensed", "eAXFT6yvz37fvS1lmt6k": "main-userWidget-chevron", "EeWTFG_vxLI5QJc1TH4F": "main-userWidget-displayName", "ERyo7m5f00o7ToFdGMCD": "main-userWidget-dropDownMenu", "pEG0W4wkbkrOYURhz82H": "main-userWidget-hasAvatar", "fw30p54zgXxgBdOMzayR": "main-userWidget-hasAvatar", "PrOCaGCRoGw7XaycfkTl": "main-userWidget-notificationDot", "EIrPk5CxH5DyLgcOY_yx": "main-userWidget-notificationIndicator", "y5mR1se0HqD3uewF5Eb6": "main-userWidget-screenReaderOnly", "YqPjzOfhtzbCf_QD3P1f": "main-userWidget-setupPlan", "RfdRTSGwulyQdDepLUTT": "main-userWidget-showDisplayName", "VdLuku6YQT4tNLT6ojlD": "main-userWidget-unableToUpdate", "NCgGw6P72qbffJyHR2Kj": "main-watchFeed-actionBodyWrapper", "SliTY9e8oKOiypDQdhlw": "main-watchFeed-actionBodyWrapper", "URPxbnUYki78KWvmWBQg": "main-watchFeed-actionFooterWrapper", "NazSI1w8xaruwFqTP3sS": "main-watchFeed-actionFooterWrapper", "_XbYNhRerNrxKQEmGVwG": "main-watchFeed-actionHeaderWrapper", "YmJS4xMTXdNQLvbP2VAt": "main-watchFeed-actionHeaderWrapper", "Ptj9KE7YszR48vu9smYv": "main-watchFeed-actionSection", "ji0qx5PBc5_4aMgNLGrp": "main-watchFeed-actionSection", "BLIxyum1wPNrFibaQ9wr": "main-watchFeed-addToPlaylistButton", "Q0Cupq3WCjD1pDiL4J7k": "main-watchFeed-addToPlaylistButton", "hrqpTk8XsoleocU2gWHD": "main-watchFeed-artistContainer", "dkbQiFBHMdijQeINl_ij": "main-watchFeed-artistContainer", "fufiJplKkge3AdhYG29S": "main-watchFeed-artistMetadataContainer", "bl3iefJaRs18m1sL93Eh": "main-watchFeed-artistMetadataContainer", "ZRW6APLCVeBbH7aeLrA6": "main-watchFeed-artistWrapper", "aiSwqZguOk1P52s9o9xP": "main-watchFeed-artistWrapper", "TfHga1ciYp079YpX_6OU": "main-watchFeed-background", "JXh51ebDftiwM49U49IA": "main-watchFeed-background", "MvqsNri2d99MH6SQlvcK": "main-watchFeed-backgroundPill", "V795GKOEZNiChr8dJcD4": "main-watchFeed-backgroundPill", "Ca5lXvgFoN6C5_gJDmEA": "main-watchFeed-backgroundSpacer", "EMcF5GoJ2mLzcp0t365T": "main-watchFeed-backgroundSpacer", "BPmQqb9D_rN4hvkCC0jg": "main-watchFeed-canvasContainer", "Ul_Er3t8veggCi1APkwp": "main-watchFeed-canvasContainer", "fhmiZPVDhaOYIlNd7Myq": "main-watchFeed-canvasWrapper", "rDA7A4UaktbiAD9VVIlB": "main-watchFeed-canvasWrapper", "F7Qc4Utwpa36JORtcEYl": "main-watchFeed-columnContainer", "JYvxp3Y58xF59FA7Mnaf": "main-watchFeed-columnContainer", "FP_XXx0FMQPJEu3WzfpM": "main-watchFeed-container", "xGrVCnYsA9aDj2f8VAj_": "main-watchFeed-container", "qgczwXDFdF_gy4gRs3LN": "main-watchFeed-content", "xvG1g4UaMyY0DW6j8XzX": "main-watchFeed-content", "cjZ23zZucJkKFMIrGZa4": "main-watchFeed-contentAnimation", "h6KcnSRTHGBMCEv288Ha": "main-watchFeed-contentAnimation", "sZNnIteDjcGaXSrb7t3W": "main-watchFeed-contentRow", "sJnarE2UKqyhLo0LFNqy": "main-watchFeed-contentRow", "MlmOtfMWMgfezN_a1qZJ": "main-watchFeed-contentSection", "ZRarvpz5og3ifOVHzGOv": "main-watchFeed-contentSection", "rCxYvbE1hq2_VEMOM6M_": "main-watchFeed-contentSelected", "yV_lauXr1J6WkogIH9dV": "main-watchFeed-contentSelected", "SySgdgYnGJ2mZyFM36JP": "main-watchFeed-contentWrapper", "YHeDUF3aquXRxYmKOs1A": "main-watchFeed-contentWrapper", "A2NgztD8CPHGPFRoT69o": "main-watchFeed-equalizerIcon", "IQsUjbPtrVLyyA5iu3zQ": "main-watchFeed-equalizerIcon", "CkAFt5XZgLBU8S0jXZgE": "main-watchFeed-flipContainer", "dErRUxXewpEkZd52uLTh": "main-watchFeed-flipContainer", "NsRfiZjjSCqTim4kM13p": "main-watchFeed-followButton", "g47sxYAVvWCq_6T4Ba5L": "main-watchFeed-followButton", "n_VwJ2YeUb7y3CHbJ3As": "main-watchFeed-genreContainer", "zrFS4My93WidFfSAb0u6": "main-watchFeed-genreContainer", "mENWYVW_PAl8OWp2Ut6e": "main-watchFeed-genreContent", "SaK6aqYnHAkgc3cG86ZW": "main-watchFeed-genreContent", "NtpYi46fEElnZKhNUPK4": "main-watchFeed-image", "iQnF1gu0Mg2rGsKVHIFn": "main-watchFeed-image", "oI9M0dyjtCBPoup_4Sxv": "main-watchFeed-imageWrapper", "bPMdcQr8gSb4AgWfkolx": "main-watchFeed-imageWrapper", "F5kvnqpgBwMGbM1mWjNS": "main-watchFeed-menuButton", "OGPXh73aLJDzhfFAdPvM": "main-watchFeed-menuButton", "Ri_fkzPuxQdOzKQf1tnw": "main-watchFeed-metadataSection", "hK5DaTfJaFpo2BJuoy5k": "main-watchFeed-metadataSection", "jlBmyjsruclK7z6ILay5": "main-watchFeed-metadataWrapper", "E0LdRRiYvrkUdZCmAXqQ": "main-watchFeed-metadataWrapper", "d9fVEV27N0lWaeQajcC2": "main-watchFeed-modal", "DJxOEatMU2PHk0cCYWoX": "main-watchFeed-modal", "RLx9IUmPpPeLH0cWnocs": "main-watchFeed-nextButtonInner", "kT3dFHdVmU0vyeE71IDE": "main-watchFeed-nextButtonInner", "iZJIqsY5Yy3szidvO6ig": "main-watchFeed-nextButtonWrapper", "XIKBfnA7TQOV1T8LLiAS": "main-watchFeed-nextButtonWrapper", "gWworaJ45IDWOdwjrZKh": "main-watchFeed-pillAnimationFour", "zs2yWiCDKxo__fskASpC": "main-watchFeed-pillAnimationFour", "wJA26c7NDa4pJmcd_khZ": "main-watchFeed-pillAnimationOne", "pGPb4TiSkhPdYEt8h2Av": "main-watchFeed-pillAnimationOne", "PAkFzXltdtGuaW_wVPZ4": "main-watchFeed-pillAnimationThree", "BWyVJ8XXZ0jRGpFaA9iW": "main-watchFeed-pillAnimationThree", "EinJaDeKltP49Pc_nCPh": "main-watchFeed-pillAnimationTwo", "FWBI05WNSb4HR8goMhai": "main-watchFeed-pillAnimationTwo", "GfTEPivZmrudh2ZLmUJE": "main-watchFeed-playerContainer", "omghgDzOcUFl6oOXYNHX": "main-watchFeed-playerContainer", "madaLABKCn3HDMHk7bBQ": "main-watchFeed-playerImage", "_bMlIQGXEMMBRZOVM71Z": "main-watchFeed-playerImage", "_C07RbGcQZqPjX3UZ9D9": "main-watchFeed-playlistTitle", "AkStH5UDLsXzudcPz_J_": "main-watchFeed-playlistTitle", "ksxwRgG3qxwghi5X7wvE": "main-watchFeed-progressBar", "edeYmiJw2Fd9EFB2SQZo": "main-watchFeed-progressBar", "w091W9_8qRMxTEGofEej": "main-watchFeed-queueButton", "d9bbNw4YiAXpQpjrSgBJ": "main-watchFeed-queueButton", "oyVGfynGkVn_6VcTnd6h": "main-watchFeed-scrollToBottomButton", "bsYqS4l_sJOvBGGeudDj": "main-watchFeed-scrollToBottomButton", "fCykqEza3WtgKmxjL0C1": "main-watchFeed-scrollToTopButton", "OcwUYvKcnj9kvPBwybGI": "main-watchFeed-scrollToTopButton", "lu2vixiUgcBKQZ3WxslE": "main-watchFeed-shareButton", "pvGZ831aNzHTQMZ8CA_u": "main-watchFeed-shareButton", "VAO32xudyOddSHyKq31b": "main-watchFeed-showBackground", "IId345zLSrSuzIWtN4Sd": "main-watchFeed-showBackground", "X6BLCPn79TtvdLQLuSEn": "main-watchFeed-songArtist", "Cypiobiw1yGi5E9N48gJ": "main-watchFeed-songArtist", "HLY280CnQhfcMxgRsmhx": "main-watchFeed-songArtistListeners", "q2y15bQqkJDcBixyP5oa": "main-watchFeed-songArtistListeners", "IJgMYRGDiAtQjDaMNfIf": "main-watchFeed-songTitle", "kJqlixofqKau1v1r3YSE": "main-watchFeed-songTitle", "LHdDNVCKKrK088dZzmuy": "main-watchFeed-songTitleWrapper", "yJJKqce9DPVQnrFseKKU": "main-watchFeed-songTitleWrapper", "x2geOlp2y1nFZSOcBBlg": "main-watchFeed-soundButtonWrapper", "Qtx4b7Wc1c7NEX49CMeH": "main-watchFeed-soundButtonWrapper", "QY_q7DYy8V1MLOWFwSdn": "main-watchFeed-willChange", "TaaunKIj990MWZKrBWHW": "main-watchFeed-willChange", "kzlksKUC9aLBM62Bckxo": "main-whatsNewFeed-actions", "bcU463yG4LHEtlBCOa8Q": "main-whatsNewFeed-buttonAlwaysVisible", "n5y3jDsz8siC0JsxtS83": "main-whatsNewFeed-buttonVisibleOnHover", "h8Dik_naJfcSoNM_4FKX": "main-whatsNewFeed-content", "lgo_zhUnwxG2Qan4WLBY": "main-whatsNewFeed-dateAndTime", "CONFZNJkrMwpneVuYWXC": "main-whatsNewFeed-description", "eyWbmr17oov600GluVsy": "main-whatsNewFeed-divider", "Z1qkHjt67N3DaWqnEM0w": "main-whatsNewFeed-imageContainer", "j45TgktnaHqgEqeO3eXI": "main-whatsNewFeed-largeImage", "pNM_LHG1Yp9WV_mBN6du": "main-whatsNewFeed-list", "nQTI7556vneVslJT_fJv": "main-whatsNewFeed-listContent", "BQD_pE0Nva_z6z7CvZww": "main-whatsNewFeed-listEpisodeBody", "ymzFgL9iToSvtRvCWY3b": "main-whatsNewFeed-listExplicitIcon", "TTFwcgaxN1VxbkjC80_Z": "main-whatsNewFeed-listFallbackIcon", "nHEYUQKiBurmQ7SD4uzF": "main-whatsNewFeed-listFooter", "LLxSqFbzfI5SPTK_22ZU": "main-whatsNewFeed-listHeader", "GBycF4NUOkA5ZUHneLYa": "main-whatsNewFeed-listImageWrapper", "LXfpmhx6aZ9YVV8x4PEI": "main-whatsNewFeed-listPlayButton", "MCctbQkmLVbJyz3NSbq2": "main-whatsNewFeed-listRow", "_MBf1tVqzfo2AefcuHwv": "main-whatsNewFeed-listRow", "ThG4UqWk7ASXCMm69Opn": "main-whatsNewFeed-listSubtitleLink", "zKLP_l7gmiomI7kW3BMw": "main-whatsNewFeed-listTimeAgo", "uuqowKOQLu0zbk4zguPM": "main-whatsNewFeed-medium", "Z3LszrbA1M2fpLsEhlQT": "main-whatsNewFeed-progressBar", "uSj66OUSURBfFwFhD7Ed": "main-whatsNewFeed-separator", "sQnWbTOGnPZBNn3lZTtI": "main-whatsNewFeed-separatorAlbum", "nzZgeVfx1Y6nZe7Z9DsA": "main-whatsNewFeed-showImage", "oezNMICqWdJHdR3QV9La": "main-yourEpisodes-coverContainer", "ioDnN5QkrvTdd8oOrl2h": "main-yourEpisodes-coverIcon", "U_iGRN8gxm_rKG_w2EzR": "main-yourEpisodes-yourEpisodesCard", "vCaNVEuqazhZFQNcVHZj": "main-yourEpisodesButton-yourEpisodesIcon", "OMCDc2F7g_AufJAtaKfL": "main-yourLibraryX-button", "cljOO1tpzixzXctKJucK": "main-yourLibraryX-button", "prGqQr33U0mG14TJ5V8a": "main-yourLibraryX-collapseButton", "FTiXRW7kAldHmLaxVQ2N": "main-yourLibraryX-collapseButton", "e_r3VdhCt6ZHTRmscHgh": "main-yourLibraryX-collapseButton", "BhKGkKPprp2wm9bvfRKG": "main-yourLibraryX-collapseButtonIsCollapsed", "RAWO6AczuDMOTI0qAc0a": "main-yourLibraryX-collapseButtonWrapper", "i_FRcsaqNCEJmoyObIP0": "main-yourLibraryX-collapseButtonWrapper", "ksmcxhImUuj3_s1lcIm0": "main-yourLibraryX-createButton", "EZFyDnuQnx5hw78phLqP": "main-yourLibraryX-entryPoints", "lHJd4oSttKLxkxuoZ0Lr": "main-yourLibraryX-entryPoints", "OR9izNpUGviUYBP_yQR2": "main-yourLibraryX-entryPoints", "wBsWS202aGdsul2kEGUf": "main-yourLibraryX-filterArea", "paiZmlAHHhmZonuGJRAr": "main-yourLibraryX-filterArea", "rjsuxO8gqIyaiYTHNpOQ": "main-yourLibraryX-filterArea", "MLbFLVC33caOj3FgSQMC": "main-yourLibraryX-filters", "msaOP0MYt3paJGpbdeJs": "main-yourLibraryX-filters", "UvXqRORKQr_N3jlgGTcS": "main-yourLibraryX-header", "HjPqU_UW2egr14mRSom9": "main-yourLibraryX-header", "tyMY5Nyfm8lQMRQ3NF8c": "main-yourLibraryX-header", "j8iKBDzqTDtnDv4XbmrK": "main-yourLibraryX-headerContent", "tAfozCQs48q1JYdphYXi": "main-yourLibraryX-headerContent", "IKza_DZb6poXFThFwlFt": "main-yourLibraryX-headerContent", "O2Vp_sNHMLHUcgMPVnOA": "main-yourLibraryX-headerIsCollapsed", "iYP0xuQiJCgi7gx1jUPJ": "main-yourLibraryX-headerIsCollapsed", "qR4uulT7QiScqfhJKJ2K": "main-yourLibraryX-headerIsCollapsed", "TxO7Ee8iwqBpkgznKHsd": "main-yourLibraryX-iconOnly", "TiJahFhH6KZaibhRtEOA": "main-yourLibraryX-isFlattened", "y2UicQnlTq148rL8Y0jp": "main-yourLibraryX-isScrolled", "hjb8tUL3rpUa0ez4ZtAj": "main-yourLibraryX-library", "wM72343CksOCaL3bZvKK": "main-yourLibraryX-library", "doInDAjF8E_MHAPBv9fb": "main-yourLibraryX-library", "g581mszC8syz99uMMWsr": "main-yourLibraryX-libraryAfterDrop", "tXkgloQ88DHF_inQE69J": "main-yourLibraryX-libraryBeforeDrop", "PpUTJL2NIYDUnmfzVIbE": "main-yourLibraryX-libraryContainer", "hgJel0bLlS_1Uf0EIfSA": "main-yourLibraryX-libraryContainer", "vlH99nxvCKwDGQrD0B0M": "main-yourLibraryX-libraryContainer", "_0FuodatXU4_fToYAuYtY": "main-yourLibraryX-libraryFilter", "uBqliBvyhxGsiql8_OJv": "main-yourLibraryX-libraryFilter", "KluIYRb68APBNGHHItUz": "main-yourLibraryX-libraryFilter", "dNphEfQzPRaQufS04jUm": "main-yourLibraryX-libraryIsCollapsed", "kJ_Q4aphh_uCJCZdzPpD": "main-yourLibraryX-libraryIsExpanded", "_XBlEstA77PgWTJzWbe1": "main-yourLibraryX-libraryItem", "_K79lE9KrIAkl_bUSSUM": "main-yourLibraryX-libraryItemContainer", "WxM1eb7qnneSkMiT4dvw": "main-yourLibraryX-libraryItemContainer", "IfMCntz4HO4NOjoFdO2v": "main-yourLibraryX-libraryItemContainer", "oLOECYtBhVmBtyisKwew": "main-yourLibraryX-libraryOnDrop", "ifVI2CEdOZGgMWIUN2Cw": "main-yourLibraryX-libraryRootlist", "_W_0W9Uld1vxrRfsgdQR": "main-yourLibraryX-libraryRootlist", "lJ0crV7IZJ8PxJZPA8x6": "main-yourLibraryX-libraryRootlist", "RGofdOZulhL2p9MRA5hg": "main-yourLibraryX-librarySort", "XZRX_ea9eNn4rOLpNGLp": "main-yourLibraryX-librarySortWrapper", "GG5skerNjHXAO6tXyyY0": "main-yourLibraryX-librarySortWrapper", "qEiVyQ28VnOKb0LeijqL": "main-yourLibraryX-listItem", "vSC5QuwmzUhqUNWdMTJ5": "main-yourLibraryX-listItem", "lCgfN9VxRRQtVKTLxWn4": "main-yourLibraryX-listItem", "ojrThQm1wxR2gZ6GntJB": "main-yourLibraryX-listItemGroup", "Dtr130mQSR0j8k7bu5KS": "main-yourLibraryX-listItemGroupCompact", "nZSNG58XEPTX69mkNi9n": "main-yourLibraryX-listRowEntityImage", "G7aCptcOZswI1fN6dGkO": "main-yourLibraryX-listRowIcon", "o_wMyH9_LbAmIwlVqsF0": "main-yourLibraryX-listRowIconWrapper", "d33vqKRxohS9RxzCic1D": "main-yourLibraryX-listRowLink", "LSrBzBljgLeDhcm3Soye": "main-yourLibraryX-listRowSubtitle", "HdTF8gsRm5MgWvEYlokG": "main-yourLibraryX-listRowSubtitleLeadingWrapper", "gj1L_SVM_H8GteWMdEF_": "main-yourLibraryX-listRowTitleLink", "LU0q0itTx2613uiATSig": "main-yourLibraryX-navItem", "KAcp7QFuEYSouAsuC5i_": "main-yourLibraryX-navItem", "AlqlOMBoMUPbFmLmkhhg": "main-yourLibraryX-navItemOffline", "fFvRIGtMIgsOLVSq_JNS": "main-yourLibraryX-navItems", "QuHe04rU4bj0Z5U9E2Tk": "main-yourLibraryX-navItems", "UYeKN11KAw61rZoyjcgZ": "main-yourLibraryX-navLink", "hNvCMxbfz7HwgzLjt3IZ": "main-yourLibraryX-navLink", "DzWw3g4E_66wu9ktqn36": "main-yourLibraryX-navLinkActive", "Bh3b80dIrbc0keQ9kdso": "main-yourLibraryX-navLinkActive", "ep0_ry7CLwf91E1rN6Cv": "main-yourLibraryX-pulse", "B_HdWVSEWPHaOf9LQAtC": "main-yourLibraryX-rowCover", "Gw7E7MkWci1ttQhb4EK0": "npv-exitFullScreenButton-button", "gIobRDHAxkAvUaF4_OOL": "npv-nowPlayingBar-center", "tr1hDrJgoPSbMXlXU_sl": "npv-nowPlayingBar-container", "mbUrqWP55sK6zhspiR72": "npv-nowPlayingBar-controls", "N5cWYDvyLrfnyMZuqQHo": "npv-nowPlayingBar-left", "FTi9QEhetf4Q4__5sb4S": "npv-nowPlayingBar-right", "SVGHXIQcH9HYU7uGITw5": "npv-nowPlayingBar-section", "pn5V0OzovI9p6b8nWq8p": "playback-bar", "KfxBdL0Zay0br7dCfxbV": "playback-bar", "IPbBrI6yF4zhaizFmrg6": "playback-bar__progress-time-elapsed", "SRm6aI_til4K8p38XAxv": "playback-bar__progress-time-elapsed", "p1ULRzPc4bD8eQ4T_wyp": "playback-progressbar", "fDGGPFmD54w05ut8Ns4S": "playback-progressbar", "MFyTjrF4GU5ab2BrrOds": "playback-progressbar", "B1vgcMXBqOxgMxXh5j1f": "playback-progressbar-container", "tNhfHQhWhj9RoA1rfwY5": "playback-progressbar-container", "DFtdzavKSbEhwKYkPTa6": "playback-progressbar-isInteractive", "WFFDxnFUICVFNvvNU1IE": "playback-progressbar-isInteractive", "OEbZDzXdU1OUQactz3ZA": "playback-progressbar-isInteractive", "gItY2hnfCB4TsDJCkPiO": "player-controls", "O56NBOTLyueNotL56zJt": "player-controls", "XrZ1iHVHAPMya3jkB2sa": "player-controls__buttons", "fYf41RQrFiGX4KJcSq_5": "player-controls__buttons", "NKUrT1GciYXAEEUtagN1": "player-controls__left", "GcbM2tnkJCvKOjRfp8RQ": "player-controls__left", "Qt226Z4rBQs53aedRQBQ": "player-controls__right", "bCCN4Fy0V1eENMKmu7pM": "player-controls__right", "JzyZE2R09wq7xtjECDeR": "playlist-inlineSearchBox-clearButton", "FeWwGSRANj36qpOBoxdx": "playlist-inlineSearchBox-filterInput", "YAYCVnYpPvmYV4JyTmn5": "playlist-inlineSearchBox-filterInputContainer", "_h5mio6VqcL_fmiXAb1S": "playlist-inlineSearchBox-overlay", "sgZ_MgcS1NlccH19fYsa": "playlist-inlineSearchBox-searchIcon", "Hfgj4Zbb2ijt8g54MlCA": "playlist-inlineSearchBox-searchIconContainer", "Bdcf5g__Rug3TGqSdbiy": "playlist-playlist-actionBarBackground-background", "CeSZLCa3sD6XCrx_ld6S": "playlist-playlist-actionBarBackground-background", "rARdlCShKVQsvuXamFOX": "playlist-playlist-artistResultListTitle", "a4FkPOXWBc0nK4yzsJCf": "playlist-playlist-concertsFooter", "IWWS0F3oiajJG7nlrjXj": "playlist-playlist-ctaLink", "mWbx87vgssexrOs2tx4I": "playlist-playlist-disclaimerContainer", "_Z2TnFjt8GB5ryOtvyti": "playlist-playlist-emptySearchTermContainer", "Bl_kg24BjWgcXPokgEKy": "playlist-playlist-emptyStateContainer", "yP3JLuwUNDIQHxRFilK3": "playlist-playlist-header", "QD13ZfPiO5otS0PU89wG": "playlist-playlist-heading", "tzeKawjOOKFw1KfQ34mG": "playlist-playlist-icon", "KzOZOlCPgREEBCJH1Ieg": "playlist-playlist-leadingSlot", "ZbLneLRe2x_OBOYZMX3M": "playlist-playlist-list", "rjdQaIDkSgcGmxkdI2vU": "playlist-playlist-listItem", "u9KYiVXeDRQDGlTDH6rM": "playlist-playlist-noBooklistSupportContainer", "umouqjSkMUbvF4I_Xz6r": "playlist-playlist-paragraph", "dZ3U5sTGUTdanNamXe1z": "playlist-playlist-playlist", "rezqw3Q4OEPB1m4rmwfw": "playlist-playlist-playlistContent", "YdoIqRxigDKvBm7a7GSq": "playlist-playlist-playlistContent", "xgmjVLxjqfcXK5BV_XyN": "playlist-playlist-playlistDescription", "fUYMR7LuRXv0KJWFvRZA": "playlist-playlist-playlistDescription", "lykOktr3YIn79xOvVQtS": "playlist-playlist-playlistDescription", "oq1ci28WPaRsWkvRiB_J": "playlist-playlist-playlistImageContainer", "hVcUafGrnsA6nD1dJzc5": "playlist-playlist-playlistInlineCurationBackButton", "FC40AOSbVM9LXjVi7bjO": "playlist-playlist-playlistInlineCurationCloseButton", "Ykd_JWqkR9gSLHISDBwP": "playlist-playlist-playlistInlineCurationSection", "SMJIXlalPk_TESlyt2pC": "playlist-playlist-playlistInlineCurationTitle", "g9xHCCSXDR8S5NvTbfwL": "playlist-playlist-playlistInlineCurationWrapper", "pbkk9BuHlY36lmWvEmbg": "playlist-playlist-promoImage", "DWkbhLMcDefEZwJ5jXCq": "playlist-playlist-promoRow", "byOUxNEoiJOtBN6xTY24": "playlist-playlist-promoTitle", "kwe0I8sSNMv3gYBjkRYP": "playlist-playlist-recommendedTrackList", "QmGi2oa43BTcEZ5MCr9T": "playlist-playlist-refreshButton", "KodyK77Gzjb8NqPGpcgw": "playlist-playlist-searchBoxContainer", "Wss0KoPtWlohVbZKp8n3": "playlist-playlist-searchBoxContainer", "sAPXlA_oxu_8x1Cn0NTC": "playlist-playlist-searchResultListContainer", "STDuzt77yRCueC4Ohenl": "playlist-playlist-seeMore", "NCKSUYdZaTMrobq8ilkc": "playlist-playlist-subtitle", "PZkwbwJD1afoCmJkGt8w": "playlist-playlist-top", "jpVuvMOCbpaRr_6FLf3W": "playlist-playlist-whiteOpacity", "kHu_FTRgoBLSLeAJtyKY": "profile-editImage-editImageButtonContainer", "vASn9mcl4gxUuplIX9Xy": "profile-editImage-editImageButtonContainer", "Ws8Ec3GREpT5PAUesr9b": "profile-editImage-imageContainer", "wCWKq_kJnqpBX20J2047": "profile-editImage-imageContainer", "zHeo4VUxytwm6Ptr0QyA": "profile-userEditDetails-closeButton", "bGC0b5iP1T6jelgvzJi6": "profile-userEditDetails-closeButton", "XwNfIrI6_hCa_9_T2cQB": "profile-userEditDetails-container", "D5Oc01xtlfhXQZqrlFzQ": "profile-userEditDetails-container", "so0bdX3oZH6YW5_nGxIR": "profile-userEditDetails-content", "pGEUqO9v_snvTufHtvLg": "profile-userEditDetails-content", "zGbjZMZ1DTx4futEbN9l": "profile-userEditDetails-disclaimer", "DD5nCHAWIgFQ7W7xUYkK": "profile-userEditDetails-disclaimer", "aM3plU4zzDqjWlvUHGYb": "profile-userEditDetails-header", "JjuNUgcpU5G8mP9fPFVx": "profile-userEditDetails-header", "F8_EX1AeKxXNSeh1qiHq": "profile-userEditDetails-image", "E_XIT3yw1mv2SGH9DwBO": "profile-userEditDetails-image", "wvLAEV5wF5C5ej6rvimT": "profile-userEditDetails-label", "P_Wd3HXMzT3JT4ftZ53c": "profile-userEditDetails-label", "gAQfzAUp1FuSXODeZJfP": "profile-userEditDetails-labelText", "zalgOdcLXMnwsiv2gej1": "profile-userEditDetails-labelText", "uj7hczcCH1dZpse8Kfmi": "profile-userEditDetails-name", "X6ndokDuE6CDp3667Fl1": "profile-userEditDetails-name", "oN9QVvJKEtdTH3HGfCu1": "profile-userEditDetails-nameInput", "DnJD_wCahoYkGOVQPqxf": "profile-userEditDetails-nameInput", "MDb7QhAtHeyM4gKj8j8t": "profile-userEditDetails-saveButton", "xNF0wfUgjZElohP3dgiS": "profile-userEditDetails-saveButton", "umiKMm5NVr5UeBJCHS6U": "profile-userOverview-container", "wDIZ2yYKjfGI68I4cZ98": "profile-userOverview-header", "rMpf7sfaPDcj387_52fA": "profile-userOverview-imageContainer", "jzhwZKbfx4vrC_MYd_7c": "profile-userOverview-section", "MWWPQQjbjRfoGdPD8D68": "profile-userOverview-subPage", "uJxNEI2k7x8UCDdMKELt": "profile-userOverview-title", "kWCnF32FrVtGHmTy8QeV": "profile-userOverview-topTrackSubPage", "TywOcKZEqNynWecCiATc": "progress-bar", "NP0jD9fPfkH_VmIJ4hEg": "progress-bar", "LS6q7yFFlYCqXoVxEETT": "progress-bar", "Vis45PPawTyED7Lt2_LI": "progress-bar__slider", "A9z4_R9gegKhgs_3D7Os": "progress-bar__slider", "sUkzLbFQJMcD0k4CVDRl": "progress-bar__slider", "DuvrswZugGajIFNXObAr": "progress-bar--isDragging", "gTvMl6pwfRD9PobMSB5x": "queue-queuePage-emptyContainer", "hNAQG0TAe2WFYyf_iZEB": "queue-queuePage-emptyContainerTitle", "Zhzrb2k9nQRActS2lp4U": "queue-queuePage-findSomething", "DG9CsoFIptJhAneKoo_F": "queue-queuePage-header", "H3Puuvc2nV0GoZRrfpRS": "queue-queuePage-nextFrom", "HckHyQocDDePWQL2baOY": "queue-queuePage-nextInQueue", "rHpv7osDRvs3SUPMpQ_g": "queue-queuePage-queuePage", "jf2HafzDEI9jn7Yo05eM": "queue-queuePage-subHeader", "oaNVBli46GtVjaQKB15g": "queue-tabBar-active", "FvDsfgxSvLvL3q8d7nQv": "queue-tabBar-chevron", "Nts_ArOCGeROTDZND3M6": "queue-tabBar-header", "muYk5XIwKmqR9iNibk_f": "queue-tabBar-headerIsCentered", "OEFWODerafYHGp09iLlA": "queue-tabBar-headerItem", "JdlKTdpMquftpMwwegZo": "queue-tabBar-headerItemLink", "m20ShRDiGGDpJ5LSABTi": "queue-tabBar-moreButton", "Hvv0e7WKQ4kyftgSQJhg": "queue-tabBar-moreButtonActive", "vhW0kRN8JJD5UwW4TdXi": "queue-tabBar-nav", "QdB2YtfEq0ks5O4QbtwX": "Root__cinema-view", "IiNKULzwo9JgYzlYVmhH": "Root__cinema-view", "FWlHsHhD0hSw1ldIXnOF": "Root__cinema-view--controls-hidden", "D8hDVfDlaGGt34V4nDGA": "Root__cinema-view--controls-hidden", "nRSfonXHVr6utXYgk2Ui": "Root__globalNav", "wp7mZFPzV7Qmo51F0NA_": "Root__globalNav", "wgtzoAN2iFV0pENAqJko": "Root__globalNav", "jEMA2gVoLgPQqAFrPhFw": "Root__main-view", "HD6RiDXIzzF9i4Bx26AE": "Root__main-view", "mMjg1Gizg9kYk8ILoTdp": "Root__main-view-overlay", "BdcvqBAid96FaHAmPYw_": "Root__nav-bar", "WBFaUw_oOfN2m4aTxggt": "Root__nav-bar", "JG5J9NWJkaUO9fiKECMA": "Root__now-playing-bar", "f9pLH3HRZQxdDLzNqKjE": "Root__now-playing-bar", "OTfMDdomT5S7B5dbYTT8": "Root__right-sidebar", "lAtoMFm8vg4yAlGztUxI": "Root__right-sidebar", "PHgyArRLVFknlaOm31ID": "Root__top-bar", "ZQftYELq0aOsg6tPbVbV": "Root__top-container", "POZtIm1wHFiwlxZY5i0a": "Root__top-container", "H1bRFdpa3qfekTVTeDwC": "Root__top-container--has-notice-bar", "WIPpgUp9J37Dwd0ZJnv0": "Root__top-container--right-sidebar-hidden", "lPapCDz3v_LipgXwe8gi": "Root__top-container--right-sidebar-hidden", "EBaPq6VUr6kjeHD9Wf2s": "Root__top-container--transition-enter", "nxyZPOEjDd5ToiXtSgdA": "Root__top-container--transition-exit", "v8nEufWSPrv1ql9ZdMko": "search-modal-emptySearchTermContainer", "sFFh5DkVxeEcgBGFOvUE": "search-modal-emptyStateContainer", "AnelgzgI75Dckf_LHUiK": "search-modal-entityImage", "aIWRvSjvEN4rTMCIi4vG": "search-modal-hasResults", "kn5N6aKpq6ebWinhrPPK": "search-modal-hidden", "v2oO4ItuH_0zk3OFj5dh": "search-modal-input", "QWeCmvys_7VINTbfUmGB": "search-modal-input", "kQ22nY00NOOrZjfmRP5J": "search-modal-inputContainer", "sKrYQkHlFOyAc0bM142q": "search-modal-isSelected", "EieXgtfUJKc4XQugVglV": "search-modal-key", "NU8xwWC0RWBRh_PBJdJe": "search-modal-keyboard-accessibility-bar", "SUOB9gChfkiToLTPl9Bc": "search-modal-listbox", "DoibN62ZFcOCvZ0f6xK5": "search-modal-modalAfterOpen", "_ZcO2wuO5d8P3TbjEukA": "search-modal-modalBase", "s75RW4QQV6LZ_NCdFtag": "search-modal-modalBeforeClose", "_p8ywioveAdTZ8yZmPfr": "search-modal-modalWrapper", "kUkjSLUuPyag37OAbVPH": "search-modal-resultItem", "ssvI7dCe2ZiLKChmS8tG": "search-modal-resultItem", "zi377dMLSwXnFiejYnRa": "search-modal-searchBar", "TQb3mB9R6qGsxvPnCurA": "search-modal-searchBar", "HN_3fmk5t15DGlzDbx1_": "search-modal-searchIcon", "NG1F0VTHk73cEJ8TNfOn": "search-modal-searchIcon", "wIyyGaSPOHR78wksX3Us": "search-modal-searchModalInstructions", "I9yJ0MC3kmodVJJlA6iq": "search-modal-searchResultRow", "tz3aYeI1uG6kMzJOPWr6": "search-modal-searchResultTitle", "TAHnl8KATdqiQLuz2TLv": "search-modal-searchResultType", "xol36rXFgZ_biOcw6Czk": "search-recentSearches-narrowPage", "khkfPsJuVBQyL_5cLT7y": "search-recentSearches-searchPageGrid", "a7lvtXATo3HALtrsOHtO": "search-recentSearches-seeAll", "rvvoAdb7aaUPYRasW7sK": "search-searchBrowse-browseAllContainer", "UdXTcsz1eiiInKThkfYp": "search-searchBrowse-browseAllWrapper", "M7LKuAFiIKaigK0fVguF": "search-searchBrowse-browseAllWrapper", "CCi1L2OQvgdZvxkRHeKE": "search-searchBrowse-SearchBrowse", "ijZQH9pePkbB2MbJHCJV": "search-searchCategory-carousel", "efpPrkQXWhVHykZxOGCQ": "search-searchCategory-carousel", "XTk61Y8OkBdUT6Wj4F6i": "search-searchCategory-carouselButton", "bsdZjMeYT0eYpTXrGNaH": "search-searchCategory-carouselButton", "VfDGbMWaJe9rcefizTNk": "search-searchCategory-carouselButtonVisible", "Nd2dSpwo9xYae8YuQIkb": "search-searchCategory-carouselButtonVisible", "KjPUGV8uMbl_0bvk9ePv": "search-searchCategory-categoryGrid", "TGtxFIzyLG46VAEOCaiI": "search-searchCategory-categoryGrid", "RXEjGtcNKiPQFxo613jX": "search-searchCategory-categoryGrid", "ZWI7JsjzJaR_G8Hy4W6J": "search-searchCategory-categoryGridItem", "UnwG2v9ISmcUhnjKj22Y": "search-searchCategory-categoryGridItem", "e179_Eg8r7Ub6yjjxctr": "search-searchCategory-container", "XAwhJzXeTM5Iv2jLMCEj": "search-searchCategory-container", "bMurPtRDRv5LuN78MTVG": "search-searchCategory-contentArea", "JDUqvfRssLaT4MgywPx0": "search-searchCategory-contentArea", "fVB_YDdnaDlztX7CcWTA": "search-searchCategory-SearchCategory", "qG4q41T8PJl0SkVgUeJc": "search-searchCategory-SearchCategory", "VIeVCUUETJyYPCDpsBif": "search-searchCategory-wrapper", "IDNDdMa6ACThrEsGWsXX": "search-searchCategory-wrapper", "nGARy02O1AklvHT7OBLA": "search-searchResult-searchResultGrid", "iGyMsGo7FgYQQThBj2y9": "search-searchResult-topResult", "eITFAR9yPwhjL_2gxB09": "search-searchResult-topResultCard", "Lj3brgOJmjcq6MQ22XKq": "search-searchResult-tracklist", "QVIrLvegL13F9cEdMqfT": "search-searchResult-tracklistContainer", "rP1oFnzzvss0GV6VPgGG": "search-searchResult-tracklistHeader", "PW9eULMYYH14XQgoJ0ui": "search-searchResult-tracklistHeaderText", "XQakH0M0GDc6g6JKeyds": "search-searchResult-tracklistHeaderWrapper", "rjgEnbv42_EUDbaiZnA2": "search-searchResult-tracklistLong", "EbZrO5qZMclA_AaI3NV8": "search-searchResult-tracklistShort", "DbMYFmOEEz9PH1h1zK9n": "show-episodeBlock-actions", "LbePDApGej12_NyRphHu": "show-episodeBlock-description", "upo8sAflD1byxWObSkgn": "show-episodeBlock-descriptionContainer", "hTRqaN61SDG95erQGMmx": "show-episodeBlock-episodeBlock", "V0pEigrddg3VxP_sTdAJ": "show-episodeBlock-header", "ij5_Bi2LfqgWwHzQBXJS": "show-episodeBlock-imageContainer", "y9kEPjDek0J80YRf8JJw": "show-episodeBlock-metadata", "o_TP9z7A8LQvMXujJC7N": "show-episodeBlock-showImage", "bG5fSAAS6rRL8xxU5iyG": "show-episodeBlock-title", "HLixBI5DbVZNC6lrUbAB": "show-episodeBlock-titleContainer", "g5gZaZVzR0tGT4pK6iEU": "show-episodeBlock-titleLink", "jtfSxoRam9rzTtdXIjzc": "show-show-episodesFilter", "ghfuv80I8uW_ymG_jfx9": "show-show-episodesFilter", "kR0M2WSYVUj4cohADSFM": "show-show-episodesHeader", "OodUnm1iCEZTUeL6X1gj": "show-show-moreButton", "aQMtxnKeiJqZ9XCcDuZ7": "show-showPage-sectionWrapper", "g3f_cI5usQX7ZOQyDtA9": "view-homeShortcutsGrid-draggable", "BKloN2GNmpktpuW0mQAs": "view-homeShortcutsGrid-draggable", "vpQWUBWS_lXRLZMRJT7w": "view-homeShortcutsGrid-episodeExtraContent", "GSv7K805J9Jw7LB9tn2A": "view-homeShortcutsGrid-episodeProgressBar", "jxXIarsEHgz2HoaVCVzA": "view-homeShortcutsGrid-equaliser", "nlOU1unbFAd7ZHyeSMTH": "view-homeShortcutsGrid-grid", "w0blQbt2NXnWLPlDFpNm": "view-homeShortcutsGrid-grid", "jdqzCrz9SoQp3ZUsU08w": "view-homeShortcutsGrid-gridOf4Columns", "gsCK1co9enL8tv4h7Cqe": "view-homeShortcutsGrid-gridOf4Columns", "ima0EKmsnCNUG08T82EM": "view-homeShortcutsGrid-iconDownloaded", "WWDxafTPs4AgThdcX5jN": "view-homeShortcutsGrid-image", "iXePCai5atydcl7RL5Mn": "view-homeShortcutsGrid-image", "jvWzgRWM_y_9FFTYRCcB": "view-homeShortcutsGrid-imageContainer", "w8fgcKCl6FoFioBbV4Zk": "view-homeShortcutsGrid-imageContainer", "zXwER4Lsqq_e7fVVaPkZ": "view-homeShortcutsGrid-imageWrapper", "KE9MrtvlIuoKtYKVr2wy": "view-homeShortcutsGrid-imageWrapper", "Tzzq1pG_inwo_oSOdyjb": "view-homeShortcutsGrid-main", "ICqWxTlKOkm9mUl_fSgS": "view-homeShortcutsGrid-main", "JFDEiqT_8B5zeG_CDSdK": "view-homeShortcutsGrid-name", "RidYQJ8faceMVnFLBEMC": "view-homeShortcutsGrid-name", "EzRmGRncgnv1zFgF4dqE": "view-homeShortcutsGrid-name", "lr4c5HqnzSyhBz8Y68M0": "view-homeShortcutsGrid-nameContainer", "TbrIq3NG2VYFoAUMSmp9": "view-homeShortcutsGrid-nameWrapper", "uy0HA5m_ehOLwt1y44DS": "view-homeShortcutsGrid-nameWrapper", "vq0lsCoYrDUDvkuUIaRg": "view-homeShortcutsGrid-playButton", "wvTlGX9H5RlvLD9JmMFD": "view-homeShortcutsGrid-playButton", "Kcb74zm1aMqGfPxTwO5s": "view-homeShortcutsGrid-PlayButtonContainer", "iuJndmfe5nTDK5FQch9C": "view-homeShortcutsGrid-PlayButtonContainer", "s9c9x_mJq197U2hBzGtV": "view-homeShortcutsGrid-PlayButtonContainerVisible", "DqAHiYCkcPCcDorezUKI": "view-homeShortcutsGrid-recentlyPlayedShortcutIcon", "Z35BWOA10YGn5uc9YgAp": "view-homeShortcutsGrid-shortcut", "mFip2ELQbL3MKPkNpa2R": "view-homeShortcutsGrid-shortcut", "jb9xD5ECTqKFK02qe3HZ": "view-homeShortcutsGrid-shortcutLink", "pE62MKOt7O7y8dGnTySw": "view-homeShortcutsGrid-shortcutLink", "bPmmKmSPLKMhtJSaUJRX": "view-homeShortcutsGrid-shortcutNewEpisodeIndicator", "rPV8BmHZXaGIGT2HwvBB": "view-homeShortcutsGrid-shortcuts", "Y89c1_2SAoZFkICK7WVp": "view-homeShortcutsGrid-shortcuts", "sLw5dxB32cAxVxqiar7J": "view-homeShortcutsGrid-title", "lt7UxuNqHQBCO6IWyA_G": "view-homeShortcutsGrid-trailingIndicator", "WPGf6HbizJpLXHLbETa5": "view-homeShortcutsGrid-trailingIndicator", "Z9WJyI5OCGSXF82DxhOH": "view-homeShortcutsGrid-trailingIndicator", "eEZSnYlv7__34b2yulfm": "volume-bar", "lhIRi4qz54GmbEZahPwS": "volume-bar", "G4n5bTzWUvlftzDwrFVG": "volume-bar", "FZhaXNtbN3Crwrgd0TA7": "volume-bar__icon-button", "rT09bwCEwXECMbLbX_7A": "volume-bar__icon-button", "GI7bVF6DBlyfIpWQKQs1": "volume-bar__icon-button", "eyxRppgmsuUUNbazkw3q": "volume-bar__slider-container", "x1jWng8HDweDS840aiIA": "volume-bar__slider-container", "tIr7C6B0Pt6YKdOnqaqj": "volume-bar__slider-container", "M_nS5sMI0NvZNLeFgFVa": "watchFeed-pillFour-keyframe", "jVGLy9ny4f46DxxtuC9z": "watchFeed-pillOne-keyframe", "DNVx4QdehkJ8two0tNVO": "watchFeed-pillThree-keyframe", "WUOx_rJjDlJ0yebh23UI": "watchFeed-pillTwo-keyframe", "fLS8v3_EfBadEerbGVoR": "x-album-releasesDropdown", "iqDYHLUedgX8y1eM1zep": "x-carousel-button", "ge8K7iQqLE77g0FDGfDn": "x-carousel-carousel", "Em2LrSSfvrgXQoajs6cm": "x-categoryCard-CategoryCard", "jXeqeqkxEBVeFjA2YydA": "x-categoryCard-CategoryCard", "tV9cjMpTPaykKsn2OVsw": "x-categoryCard-image", "Op2n2H4o1iY0Xo2wAUH9": "x-categoryCard-image", "i2yp6pOoZpYZLd5QWguN": "x-categoryCard-title", "bQthUEx0_U98DJkT1saO": "x-categoryCard-title", "jMg2yhvAA3YfgM1Ix5GL": "x-contributorButton-button", "X1lXSiVj0pzhQCUo_72A": "x-contributorButton-ContributorButton", "HbKLiGoYM4dpuK8L4TMX": "x-downloadButton-button", "VmwiDoU6RpqyzK_n7XRO": "x-downloadButton-cancelDownload", "l_MW0G9qeeCKlVJwBykT": "x-downloadButton-cancelDownloadButton", "BKsbV2Xl786X9a09XROH": "x-downloadButton-DownloadButton", "GWCBhKJqeZal3n5tCQwl": "x-downloadButton-DownloadButton", "rEx3EYgBzS8SoY7dmC6x": "x-downloadButton-progress", "_APVWqivXc4YqgsnpFkP": "x-downloadButton-removeDownload", "OadpZJiOaGfX6Qp4j6n5": "x-entityImage-circle", "AeEoI6ueagbJtaHl2cRd": "x-entityImage-defaultSize", "iJp40IxKg6emF6KYJ414": "x-entityImage-image", "vreceNX3ABcxyddeS83B": "x-entityImage-imageContainer", "lKuMkIKSZanMIK6aQWfx": "x-entityImage-imageContainer", "Ozitxbqs1vcOukDz3GDw": "x-entityImage-imagePlaceholder", "Toq4yy4bj8GGMSF5oWbj": "x-entityImage-imagePlaceholder", "SBpny8HrUTBzSjk7Vtk1": "x-entityImage-large", "H71KtIrytVayf_dFofu7": "x-entityImage-medium", "O5_0cReFdHe81E0xFAD1": "x-entityImage-small", "CxurIfvXVb_TqGF4q8Yf": "x-entityImage-square", "g3kBhX1E4EYEC2NFhhxG": "x-entityImage-xsmall", "hn6wKYDgRJLk3ObMeArw": "x-entityImage-xsmall", "SgFtsvn3upY_tG6mnt4n": "x-explicit-icon", "zUF7IRW3lpivOZHjWRko": "x-explicit-icon", "Ps9zgW56WZaBVLo1n3cg": "x-explicit-label", "CBiDL2Ry7eHHcTjB4SME": "x-explicit-label", "EsqEJ_NPrHsPkTbX4FW8": "x-filterBox-clearButton", "wCl7pMTEE68v1xuZeZiB": "x-filterBox-expandButton", "kFl3NAPY_5yenNQk7VpZ": "x-filterBox-expandButton", "t6HIrX67Lp80Nj6tGauz": "x-filterBox-expandedOrHasFilter", "_quDRjKzF4E0L6Qv0l_F": "x-filterBox-expandedOrHasFilter", "KAydWoHSkQRqhQ1vkVwK": "x-filterBox-expandRight", "QZhV0hWVKlExlKr266jo": "x-filterBox-filterInput", "A1ZtjTtf0TzOYLOBjaQe": "x-filterBox-filterInput", "JzZyf6OGCGtdscOZGt8Y": "x-filterBox-filterInputContainer", "CNJRzwGSOPxBBfje5FEK": "x-filterBox-filterInputContainer", "iiNt4l0UwY2wL42vyBc2": "x-filterBox-fullWidth", "uAJxc_psYWeimY8N9bH9": "x-filterBox-overlay", "nt15x8uJhSbH9flWJ8r6": "x-filterBox-overlay", "CIVozJ8XNPJ60uMN23Yg": "x-filterBox-searchIcon", "AFy8ta8zNE0nUMR7Dwdk": "x-filterBox-searchIcon", "_bjbHn5TABOW2s5LsEGX": "x-filterBox-searchIconContainer", "_oM_IwFBK0iLCBHKYvEp": "x-filterBox-searchIconContainer", "epWhU7hHGktzlO_dop6z": "x-progressBar-fillColor", "pghDjOdspOgysFT72xOx": "x-progressBar-fillColor", "JZ3wV4Y5GgAD0UFsQpE7": "x-progressBar-fillColor", "NoOAOv6U6vtqj_ybS1Cd": "x-progressBar-progressBarBg", "BDW4CFlIaMu9sHJRFCCg": "x-progressBar-progressBarBg", "NuZQwzwm2yKxIGrFO_y4": "x-progressBar-progressBarBg", "w699O0LgQRghXyl3bs9u": "x-progressBar-sliderArea", "JRShuZ9K3QC6x8Nl0FMu": "x-progressBar-sliderArea", "nfLzZOKzyFjyut6toQIa": "x-progressBar-sliderArea", "SfuwkNwMOfcRX0KF6cPa": "x-progressBar-wrapper", "ENGp3mqoFqfGLHott9_k": "x-progressBar-enabled", "eVonGEFHbUE9S2xfVA8d": "x-progressBar-progressBar", "jLzAN9dQidF6eUHqVFk_": "x-progressBar-background", "uVRQz1zaxSUQVQ0s06iw": "x-progressBar-foregroundWrapper", "Xm6MvwqQLnIU1TANX6Rt": "x-progressBar-middleground", "vSotTDmUOWTotCYudlgI": "x-progressBar-foreground", "snYg67O_WNutGrCsqzIZ": "x-progressBar-tooltipAnchor", "REMgVhoMClSNRZve5xaJ": "x-progressBar-handle", "SjP3wobd2iE2kK8vEIsg": "x-progressBar-progressAnimatable", "_DrNS3e4ylFBzHyCWDF0": "x-progressBar-disableProgressAnimatable", "edMDVHlUyeCR51HK_LGe": "x-progressBar-progressBarActive", "UHDS6Tgm15et79EOkj_9": "x-progressBar-saberEnabled", "mKfEQ7SMFopnDLydx4HP": "x-progressBar-chapterHoverOverlay", "AavUTirpKSAHapguSAy0": "x-proxySettings-fullWidth", "qfjicQPaTTPrLWJWRxMQ": "x-proxySettings-horizontalPair", "YZUWpDgQNo3KQwdRQHh3": "x-proxySettings-horizontalPair", "acVhMt5pELcXQyLaaPuV": "x-proxySettings-ProxySettings", "k5yrn4bTRFh4dMUyEtX2": "x-proxySettings-ProxySettings", "fOEOTcOAgPryvbYRYfOo": "x-searchHistoryEntries-clearSingleSearchHistory", "bONdaZB1x9i_WIeOBxEC": "x-searchHistoryEntries-clearSingleSearchHistory", "xmJl0s8mcJ3bfhtnoaP1": "x-searchHistoryEntries-clearSingleSearchHistoryButton", "fTpRygNKlEhmZyrgy6hD": "x-searchHistoryEntries-clearSingleSearchHistoryButton", "ADri2r8kq8LVqSsNNvIr": "x-searchHistoryEntries-searchHistoryEntry", "rKHLepPL9WCKR7yfqS1s": "x-searchHistoryEntries-searchHistoryEntry", "ZtY42R4YSo_W7VMeAg9m": "x-searchInput-searchInputClearButton", "LWugd2SlihapRT0PX8K3": "x-searchInput-searchInputClearButton", "mOLTJ2mxkzHJj6Y9_na_": "x-searchInput-searchInputClearIcon", "t2K4_iLmAyDtH7mcT5Wy": "x-searchInput-searchInputIconContainer", "lJ81QkQ1Aw5AK50afb9c": "x-searchInput-searchInputIconContainer", "QO9loc33XC50mMRUCIvf": "x-searchInput-searchInputInput", "NtkAQg9R1r5CjuP0XHwl": "x-searchInput-searchInputInput", "Mx356zpxhMqMxje_7QXv": "x-searchInput-searchInputInput", "XD3TMuMHmKsfbqieC6q_": "x-searchInput-searchInputOnSearch", "H6jh9Xd7DNOq3NsLDmCB": "x-searchInput-searchInputSearchIcon", "aCQCOVyiFROs_qr_DvQ4": "x-searchInput-searchInputSearchIcon", "MpoH5sdgCUbPL5LCl3Cy": "x-searchInput-searchInputSearchIcon", "rFFJg1UIumqUUFDgo6n7": "x-settings-button", "l_pugTMA53sS_K65iEiW": "x-settings-button", "LOsH9AUZc2uFRlhqtpRT": "x-settings-container", "xQ33L7BC8Xbpdjo1gCAU": "x-settings-container", "xbm3VfL2kntDlxtyDKwj": "x-settings-crossFadeContainer", "gO8pl5RByZlVaPDbaAVu": "x-settings-crossFadeContainer", "gv7Rcc2ouDRSd0pto7Df": "x-settings-equalizerPanelCanvas", "i9YYQi3sdgQiftnT0G7l": "x-settings-equalizerPanelCanvas", "zrn877LGjVA_oYp2IKeu": "x-settings-equalizerPanelFilter", "KBoHP10jGFBIjRt0TilW": "x-settings-equalizerPanelFilter", "aOnWJeLmOj8plFB5QPSt": "x-settings-equalizerPanelFilters", "yApUWWS53HH6f8UXAOrD": "x-settings-equalizerPanelFilters", "L9sAZBDUVTnJLn7TqR1E": "x-settings-equalizerPanelGainLabel", "DgABXUkFFAu6h3dxMlER": "x-settings-equalizerPanelGainLabel", "aPIPHZU8F7TMi6LEw_Yq": "x-settings-equalizerPanelGainLabelDown", "W5vAyrwphGDgfEcVvWCG": "x-settings-equalizerPanelGainLabelDown", "YKGOZh1y8HVOqQgbJB9F": "x-settings-equalizerPanelGainLabelUp", "L6g4d49oWcb89EH_UvI1": "x-settings-equalizerPanelGainLabelUp", "KGnDzV9IPjGQ8Ude8Cgl": "x-settings-equalizerPanelInput", "N4bVba_JCN915eH2yz7O": "x-settings-equalizerPanelInput", "NWmefh0djDBBQmHr_mdf": "x-settings-equalizerPanelLabel", "LpIve1Boio_yH1npDnzz": "x-settings-equalizerPanelLabel", "PumO62RLWafcexCJx0oe": "x-settings-equalizerPanelPreset", "HGN6qJ4noxOV2YSJWj3g": "x-settings-equalizerPanelWrapper", "S20UWmwnvAky4mj2zHXQ": "x-settings-equalizerPanelWrapper", "j7cPtD65ArW8eWnGNrUo": "x-settings-equalizerPresetsContainer", "xPMt4zCAUHum2VzFuHhR": "x-settings-equalizerPresetsContainer", "ZzNb8P1Bz1VHtlhimIWM": "x-settings-equalizerPresetsLabel", "mJOG8oySLSgkKcRKQEcw": "x-settings-equalizerPresetsLabel", "PiFWoUIRceOm8SHTCakS": "x-settings-equalizerResetButtonWrapper", "p5IaREhBZJIUrqcK5ifE": "x-settings-equalizerResetButtonWrapper", "xK6HEWejcSHKyWfhNiJc": "x-settings-equalizerSection", "PZqn_zrcjFHRxNwlhrCY": "x-settings-equalizerSection", "FulR95cAh4QPdw6wUeRw": "x-settings-equalizerWrapper", "hdG4NraLUWvgQWKmA3PQ": "x-settings-equalizerWrapper", "GMGmbx5FRBd6DOVvzSgk": "x-settings-firstColumn", "g2SG95QPZfbn5RINccth": "x-settings-firstColumn", "lfXDZUXLhhKhFPjDO8by": "x-settings-firstColumn", "NnDXf1J9xlVM5AUuqVt1": "x-settings-header", "Euj6Lot2A7ir5T4xBiTf": "x-settings-header", "DQ9fp5DjBJxKHeHqtFwC": "x-settings-headerContainer", "lcSvuotO0TXX8S7X6D_A": "x-settings-headerContainer", "V4Bh2Ch7KvYUdn2s9ZdX": "x-settings-hidden", "SkbGMKYv49KtJNB5XxdX": "x-settings-input", "QDmFXu7LLdLf6M3BASsu": "x-settings-input", "gvcgOXnAiNKEe_z92_lw": "x-settings-restartAppButton", "gdRu9OyPL_MvmSMgE042": "x-settings-restartAppButton", "weV_qxFz4gF5sPotO10y": "x-settings-row", "BMtRRwqaJD_95vJFMFD0": "x-settings-row", "eguwzH_QWTBXry7hiNj3": "x-settings-row", "yNitN64xoLNhzJlkfzOh": "x-settings-secondColumn", "rtzkwMH3kqwgnS_BxP_t": "x-settings-secondColumn", "jKCZodyn7H2Trr7dhvGm": "x-settings-secondColumn", "c6TyNYOUJRIsjYZJZofy": "x-settings-section", "k6GFKyOKVR5Ruofj3aTQ": "x-settings-section", "YtAW7cQal8op8H9JkJ8T": "x-settings-section", "GuwMf98GUBSpCDgf8KRA": "x-settings-tooltip", "NLh3bMez5RC2mwsYOf2g": "x-settings-tooltip", "nW1RKQOkzcJcX6aDCZB4": "x-settings-tooltipIcon", "RDrhzf8eX7cW85q3CuCw": "x-settings-tooltipIcon", "qBZYab2T7Yc4O5Nh0mjA": "x-settings-tooltipIconWrapper", "m8DaD7yVdc8BD9wWBC2Z": "x-settings-tooltipIconWrapper", "l35q2le3C8eAxw1TKELD": "x-settings-wordBreakAll", "w6j_vX6SF5IxSXrrkYw5": "x-sortBox-sortDropdown", "fvlp70y1LlEYYjgC8yEs": "x-sortBox-sortDropdown", "es9mguuOfkp6pBe1Bjlw": "x-toggle-indicator", "sxTbfT6ioOgvOvHzaBE7": "x-toggle-indicator", "Js64TOfWtHksI6TQ6knT": "x-toggle-indicatorWrapper", "Qb0gCQFXpstteRqnAF9q": "x-toggle-indicatorWrapper", "n8tsDTs8wDH73kejYfXs": "x-toggle-input", "Smo4wLHtFoFOOsJP0evo": "x-toggle-input", "ztL0S6Lyoye5upzDS_yU": "x-toggle-wrapper", "JWYoNAyrIIdW30u4PSGE": "x-toggle-wrapper" } ================================================ FILE: globals.d.ts ================================================ declare namespace Spicetify { type Icon = | "album" | "artist" | "block" | "brightness" | "car" | "chart-down" | "chart-up" | "check" | "check-alt-fill" | "chevron-left" | "chevron-right" | "chromecast-disconnected" | "clock" | "collaborative" | "computer" | "copy" | "download" | "downloaded" | "edit" | "enhance" | "exclamation-circle" | "external-link" | "facebook" | "follow" | "fullscreen" | "gamepad" | "grid-view" | "heart" | "heart-active" | "instagram" | "laptop" | "library" | "list-view" | "location" | "locked" | "locked-active" | "lyrics" | "menu" | "minimize" | "minus" | "more" | "new-spotify-connect" | "offline" | "pause" | "phone" | "play" | "playlist" | "playlist-folder" | "plus-alt" | "plus2px" | "podcasts" | "projector" | "queue" | "repeat" | "repeat-once" | "search" | "search-active" | "shuffle" | "skip-back" | "skip-back15" | "skip-forward" | "skip-forward15" | "soundbetter" | "speaker" | "spotify" | "subtitles" | "tablet" | "ticket" | "twitter" | "visualizer" | "voice" | "volume" | "volume-off" | "volume-one-wave" | "volume-two-wave" | "watch" | "x"; type Variant = | "bass" | "forte" | "brio" | "altoBrio" | "alto" | "canon" | "celloCanon" | "cello" | "ballad" | "balladBold" | "viola" | "violaBold" | "mesto" | "mestoBold" | "metronome" | "finale" | "finaleBold" | "minuet" | "minuetBold"; type SemanticColor = | "textBase" | "textSubdued" | "textBrightAccent" | "textNegative" | "textWarning" | "textPositive" | "textAnnouncement" | "essentialBase" | "essentialSubdued" | "essentialBrightAccent" | "essentialNegative" | "essentialWarning" | "essentialPositive" | "essentialAnnouncement" | "decorativeBase" | "decorativeSubdued" | "backgroundBase" | "backgroundHighlight" | "backgroundPress" | "backgroundElevatedBase" | "backgroundElevatedHighlight" | "backgroundElevatedPress" | "backgroundTintedBase" | "backgroundTintedHighlight" | "backgroundTintedPress" | "backgroundUnsafeForSmallTextBase" | "backgroundUnsafeForSmallTextHighlight" | "backgroundUnsafeForSmallTextPress"; type ColorSet = | "base" | "brightAccent" | "negative" | "warning" | "positive" | "announcement" | "invertedDark" | "invertedLight" | "mutedAccent" | "overMedia"; type ColorSetBackgroundColors = { base: string; highlight: string; press: string; }; type ColorSetNamespaceColors = { announcement: string; base: string; brightAccent: string; negative: string; positive: string; subdued: string; warning: string; }; type ColorSetBody = { background: ColorSetBackgroundColors & { elevated: ColorSetBackgroundColors; tinted: ColorSetBackgroundColors; unsafeForSmallText: ColorSetBackgroundColors; }; decorative: { base: string; subdued: string; }; essential: ColorSetNamespaceColors; text: ColorSetNamespaceColors; }; type Metadata = Partial>; type ContextTrack = { uri: string; uid?: string; metadata?: Metadata; }; type PlayerState = { timestamp: number; context: PlayerContext; index: PlayerIndex; item: PlayerTrack; shuffle: boolean; smartShuffle: boolean; repeat: number; speed: number; positionAsOfTimestamp: number; duration: number; hasContext: boolean; isPaused: boolean; isBuffering: boolean; restrictions: Restrictions; previousItems?: PlayerTrack[]; nextItems?: PlayerTrack[]; playbackQuality: PlaybackQuality; playbackId: string; sessionId: string; signals?: any[]; }; type PlayerContext = { uri: string; url: string; metadata: { "player.arch": string; }; }; type PlayerIndex = { pageURI?: string | null; pageIndex: number; itemIndex: number; }; type PlayerTrack = { type: string; uri: string; uid: string; name: string; mediaType: string; duration: { milliseconds: number; }; album: Album; artists?: ArtistsEntity[]; isLocal: boolean; isExplicit: boolean; is19PlusOnly: boolean; provider: string; metadata: TrackMetadata; images?: ImagesEntity[]; }; type TrackMetadata = { artist_uri: string; entity_uri: string; iteration: string; title: string; "collection.is_banned": string; "artist_uri:1": string; "collection.in_collection": string; image_small_url: string; "collection.can_ban": string; is_explicit: string; album_disc_number: string; album_disc_count: string; track_player: string; album_title: string; "canvas.artist.avatar": string; "canvas.artist.name": string; "canvas.artist.uri": string; "canvas.canvasUri": string; "canvas.entityUri": string; "canvas.explicit": string; "canvas.fileId": string; "canvas.id": string; "canvas.type": string; "canvas.uploadedBy": string; "canvas.url": string; "collection.can_add": string; image_large_url: string; "actions.skipping_prev_past_track": string; page_instance_id: string; image_xlarge_url: string; marked_for_download: string; "actions.skipping_next_past_track": string; context_uri: string; "artist_name:1": string; has_lyrics: string; interaction_id: string; image_url: string; album_uri: string; album_artist_name: string; album_track_number: string; artist_name: string; duration: string; album_track_count: string; popularity: string; associated_video_id: string; video_association: string; video_association_image: string; video_association_image_height: string; video_association_image_height_large: string; video_association_image_height_xxlarge: string; video_association_image_large: string; video_association_image_width: string; video_association_image_width_large: string; video_association_image_width_xxlarge: string; video_association_image_xxlarge: string; [key: string]: string; }; type Album = { type: string; uri: string; name: string; images?: ImagesEntity[]; }; type ImagesEntity = { url: string; label: string; }; type ArtistsEntity = { type: string; uri: string; name: string; }; type Restrictions = { canPause: boolean; canResume: boolean; canSeek: boolean; canSkipPrevious: boolean; canSkipNext: boolean; canToggleRepeatContext: boolean; canToggleRepeatTrack: boolean; canToggleShuffle: boolean; disallowPausingReasons?: string[]; disallowResumingReasons?: string[]; disallowSeekingReasons?: string[]; disallowSkippingPreviousReasons?: string[]; disallowSkippingNextReasons?: string[]; disallowTogglingRepeatContextReasons?: string[]; disallowTogglingRepeatTrackReasons?: string[]; disallowTogglingShuffleReasons?: string[]; disallowTransferringPlaybackReasons?: string[]; }; type PlaybackQuality = { bitrateLevel: number; strategy: number; targetBitrateLevel: number; targetBitrateAvailable: boolean; hifiStatus: number; }; namespace Player { /** * * Contains vast array of internal APIs. * Please explore in Devtool Console. */ const origin: any; /** * Register a listener `type` on Spicetify.Player. * * On default, `Spicetify.Player` always dispatch: * - `songchange` type when player changes track. * - `onplaypause` type when player plays or pauses. * - `onprogress` type when track progress changes. * - `appchange` type when user changes page. */ function addEventListener(type: string, callback: (event?: Event) => void): void; function addEventListener(type: "songchange", callback: (event?: Event & { data: PlayerState }) => void): void; function addEventListener(type: "onplaypause", callback: (event?: Event & { data: PlayerState }) => void): void; function addEventListener(type: "onprogress", callback: (event?: Event & { data: number }) => void): void; function addEventListener( type: "appchange", callback: ( event?: Event & { data: { /** * App href path */ path: string; /** * App container */ container: HTMLElement; }; } ) => void ): void; /** * Skip to previous track. */ function back(): void; /** * An object contains all information about current track and player. */ const data: PlayerState; /** * Decrease a small amount of volume. */ function decreaseVolume(): void; /** * Dispatches an event at `Spicetify.Player`. * * On default, `Spicetify.Player` always dispatch * - `songchange` type when player changes track. * - `onplaypause` type when player plays or pauses. * - `onprogress` type when track progress changes. * - `appchange` type when user changes page. */ function dispatchEvent(event: Event): void; const eventListeners: { [key: string]: Array<(event?: Event) => void>; }; /** * Convert milisecond to `mm:ss` format * @param milisecond */ function formatTime(milisecond: number): string; /** * Return song total duration in milisecond. */ function getDuration(): number; /** * Return mute state */ function getMute(): boolean; /** * Return elapsed duration in milisecond. */ function getProgress(): number; /** * Return elapsed duration in percentage (0 to 1). */ function getProgressPercent(): number; /** * Return current Repeat state (No repeat = 0/Repeat all = 1/Repeat one = 2). */ function getRepeat(): number; /** * Return current shuffle state. */ function getShuffle(): boolean; /** * Return track heart state. */ function getHeart(): boolean; /** * Return current volume level (0 to 1). */ function getVolume(): number; /** * Increase a small amount of volume. */ function increaseVolume(): void; /** * Return a boolean whether player is playing. */ function isPlaying(): boolean; /** * Skip to next track. */ function next(): void; /** * Pause track. */ function pause(): void; /** * Resume track. */ function play(): void; /** * Play a track, playlist, album, etc. immediately * @param uri Spotify URI * @param context * @param options */ function playUri(uri: string, context?: any, options?: any): Promise; /** * Unregister added event listener `type`. * @param type * @param callback */ function removeEventListener(type: string, callback: (event?: Event) => void): void; /** * Seek track to position. * @param position can be in percentage (0 to 1) or in milisecond. */ function seek(position: number): void; /** * Turn mute on/off * @param state */ function setMute(state: boolean): void; /** * Change Repeat mode * @param mode `0` No repeat. `1` Repeat all. `2` Repeat one track. */ function setRepeat(mode: number): void; /** * Turn shuffle on/off. * @param state */ function setShuffle(state: boolean): void; /** * Set volume level * @param level 0 to 1 */ function setVolume(level: number): void; /** * Seek to previous `amount` of milisecond * @param amount in milisecond. Default: 15000. */ function skipBack(amount?: number): void; /** * Seek to next `amount` of milisecond * @param amount in milisecond. Default: 15000. */ function skipForward(amount?: number): void; /** * Toggle Heart (Favourite) track state. */ function toggleHeart(): void; /** * Toggle Mute/No mute. */ function toggleMute(): void; /** * Toggle Play/Pause. */ function togglePlay(): void; /** * Toggle No repeat/Repeat all/Repeat one. */ function toggleRepeat(): void; /** * Toggle Shuffle/No shuffle. */ function toggleShuffle(): void; } /** * Adds a track or array of tracks to prioritized queue. */ function addToQueue(uri: ContextTrack[]): Promise; /** * @deprecated */ const BridgeAPI: any; /** * @deprecated */ const CosmosAPI: any; /** * Async wrappers of CosmosAPI */ namespace CosmosAsync { type Method = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT" | "SUB"; interface Error { code: number; error: string; message: string; stack?: string; } type Headers = Record; type Body = Record; interface Response { body: any; headers: Headers; status: number; uri?: string; } function head(url: string, headers?: Headers): Promise; function get(url: string, body?: Body, headers?: Headers): Promise; function post(url: string, body?: Body, headers?: Headers): Promise; function put(url: string, body?: Body, headers?: Headers): Promise; function del(url: string, body?: Body, headers?: Headers): Promise; function patch(url: string, body?: Body, headers?: Headers): Promise; function sub( url: string, callback: (b: Response["body"]) => void, onError?: (e: Error) => void, body?: Body, headers?: Headers ): Promise; function postSub( url: string, body: Body | null, callback: (b: Response["body"]) => void, onError?: (e: Error) => void ): Promise; function request(method: Method, url: string, body?: Body, headers?: Headers): Promise; function resolve(method: Method, url: string, body?: Body, headers?: Headers): Promise; } /** * Fetch interesting colors from URI. * @param uri Any type of URI that has artwork (playlist, track, album, artist, show, ...) */ function colorExtractor(uri: string): Promise<{ DARK_VIBRANT: string; DESATURATED: string; LIGHT_VIBRANT: string; PROMINENT: string; VIBRANT: string; VIBRANT_NON_ALARMING: string; }>; /** * @deprecated */ function getAblumArtColors(): any; /** * Fetch track analyzed audio data. * Beware, not all tracks have audio data. * @param uri is optional. Leave it blank to get current track * or specify another track uri. */ function getAudioData(uri?: string): Promise; /** * Set of APIs method to register, deregister hotkeys/shortcuts */ namespace Keyboard { type ValidKey = | "BACKSPACE" | "TAB" | "ENTER" | "SHIFT" | "CTRL" | "ALT" | "CAPS" | "ESCAPE" | "SPACE" | "PAGE_UP" | "PAGE_DOWN" | "END" | "HOME" | "ARROW_LEFT" | "ARROW_UP" | "ARROW_RIGHT" | "ARROW_DOWN" | "INSERT" | "DELETE" | "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X" | "Y" | "Z" | "WINDOW_LEFT" | "WINDOW_RIGHT" | "SELECT" | "NUMPAD_0" | "NUMPAD_1" | "NUMPAD_2" | "NUMPAD_3" | "NUMPAD_4" | "NUMPAD_5" | "NUMPAD_6" | "NUMPAD_7" | "NUMPAD_8" | "NUMPAD_9" | "MULTIPLY" | "ADD" | "SUBTRACT" | "DECIMAL_POINT" | "DIVIDE" | "F1" | "F2" | "F3" | "F4" | "F5" | "F6" | "F7" | "F8" | "F9" | "F10" | "F11" | "F12" | ";" | "=" | " | " | "-" | "." | "/" | "`" | "[" | "\\" | "]" | '"' | "~" | "!" | "@" | "#" | "$" | "%" | "^" | "&" | "*" | "(" | ")" | "_" | "+" | ":" | "<" | ">" | "?" | "|"; type KeysDefine = | string | { key: string; ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean; }; const KEYS: Record; function registerShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; function registerIsolatedShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; function registerImportantShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; function _deregisterShortcut(keys: KeysDefine): void; function deregisterImportantShortcut(keys: KeysDefine): void; function changeShortcut(keys: KeysDefine, newKeys: KeysDefine): void; } /** * @deprecated */ const LiveAPI: any; namespace LocalStorage { /** * Empties the list associated with the object of all key/value pairs, if there are any. */ function clear(): void; /** * Get key value */ function get(key: string): string | null; /** * Delete key */ function remove(key: string): void; /** * Set new value for key */ function set(key: string, value: string): void; } /** * To create and prepend custom menu item in profile menu. */ namespace Menu { /** * Create a single toggle. */ class Item { constructor(name: string, isEnabled: boolean, onClick: (self: Item) => void, icon?: Icon | string); name: string; isEnabled: boolean; /** * Change item name */ setName(name: string): void; /** * Change item enabled state. * Visually, item would has a tick next to it if its state is enabled. */ setState(isEnabled: boolean): void; /** * Change icon */ setIcon(icon: Icon | string): void; /** * Item is only available in Profile menu when method "register" is called. */ register(): void; /** * Stop item to be prepended into Profile menu. */ deregister(): void; } /** * Create a sub menu to contain Item toggles. * `Item`s in `subItems` array shouldn't be registered. */ class SubMenu { constructor(name: string, subItems: Item[]); name: string; /** * Change SubMenu name */ setName(name: string): void; /** * Add an item to sub items list */ addItem(item: Item): void; /** * Remove an item from sub items list */ removeItem(item: Item): void; /** * SubMenu is only available in Profile menu when method "register" is called. */ register(): void; /** * Stop SubMenu to be prepended into Profile menu. */ deregister(): void; } } /** * Keyboard shortcut library * * Documentation: https://craig.is/killing/mice v1.6.5 * * Spicetify.Keyboard is wrapper of this library to be compatible with legacy Spotify, * so new extension should use this library instead. */ function Mousetrap(element?: any): void; /** * Contains vast array of internal APIs. * Please explore in Devtool Console. */ const Platform: any; /** * Queue object contains list of queuing tracks, * history of played tracks and current track metadata. */ const Queue: { nextTracks: any[]; prevTracks: any[]; queueRevision: string; track: any; }; /** * Remove a track or array of tracks from current queue. */ function removeFromQueue(uri: ContextTrack[]): Promise; /** * Display a bubble of notification. Useful for a visual feedback. * @param message Message to display. Can use inline HTML for styling. * @param isError If true, bubble will be red. Defaults to false. * @param msTimeout Time in milliseconds to display the bubble. Defaults to Spotify's value. */ function showNotification(message: React.ReactNode, isError?: boolean, msTimeout?: number): void; /** * Set of APIs method to parse and validate URIs. */ class URI { constructor(type: string, props: any); public type: string; public hasBase62Id: boolean; public id?: string; public disc?: any; public args?: any; public category?: string; public username?: string; public track?: string; public artist?: string; public album?: string; public duration?: number; public query?: string; public country?: string; public global?: boolean; public context?: string | typeof URI | null; public anchor?: string; public play?: any; public toplist?: any; /** * * @return The URI representation of this uri. */ toURI(): string; /** * * @return The URI representation of this uri. */ toString(): string; /** * Get the URL path of this uri. * * @param opt_leadingSlash True if a leading slash should be prepended. * @return The path of this uri. */ toURLPath(opt_leadingSlash: boolean): string; /** * * @param origin The origin to use for the URL. * @return The URL string for the uri. */ toURL(origin?: string): string; /** * Clones a given SpotifyURI instance. * * @return An instance of URI. */ clone(): URI | null; /** * Gets the path of the URI object by removing all hash and query parameters. * * @return The path of the URI object. */ getPath(): string; /** * The various URI Types. * * Note that some of the types in this enum are not real URI types, but are * actually URI particles. They are marked so. * */ static Type: { AD: string; ALBUM: string; GENRE: string; QUEUE: string; APPLICATION: string; ARTIST: string; ARTIST_TOPLIST: string; ARTIST_CONCERTS: string; AUDIO_FILE: string; COLLECTION: string; COLLECTION_ALBUM: string; COLLECTION_ARTIST: string; COLLECTION_MISSING_ALBUM: string; COLLECTION_TRACK_LIST: string; CONCERT: string; CONTEXT_GROUP: string; DAILY_MIX: string; EMPTY: string; EPISODE: string; /** URI particle; not an actual URI. */ FACEBOOK: string; FOLDER: string; FOLLOWERS: string; FOLLOWING: string; IMAGE: string; INBOX: string; INTERRUPTION: string; LIBRARY: string; LIVE: string; ROOM: string; EXPRESSION: string; LOCAL: string; LOCAL_TRACK: string; LOCAL_ALBUM: string; LOCAL_ARTIST: string; MERCH: string; MOSAIC: string; PLAYLIST: string; PLAYLIST_V2: string; PRERELEASE: string; PROFILE: string; PUBLISHED_ROOTLIST: string; RADIO: string; ROOTLIST: string; SEARCH: string; SHOW: string; SOCIAL_SESSION: string; SPECIAL: string; STARRED: string; STATION: string; TEMP_PLAYLIST: string; TOPLIST: string; TRACK: string; TRACKSET: string; USER_TOPLIST: string; USER_TOP_TRACKS: string; UNKNOWN: string; MEDIA: string; QUESTION: string; POLL: string; }; /** * Creates a new URI object from a parsed string argument. * * @param str The string that will be parsed into a URI object. * @throws TypeError If the string argument is not a valid URI, a TypeError will * be thrown. * @return The parsed URI object. */ static fromString(str: string): URI; /** * Parses a given object into a URI instance. * * Unlike URI.fromString, this function could receive any kind of value. If * the value is already a URI instance, it is simply returned. * Otherwise the value will be stringified before parsing. * * This function also does not throw an error like URI.fromString, but * instead simply returns null if it can't parse the value. * * @param value The value to parse. * @return The corresponding URI instance, or null if the * passed value is not a valid value. */ static from(value: any): URI | null; /** * Checks whether two URI:s refer to the same thing even though they might * not necessarily be equal. * * These two Playlist URIs, for example, refer to the same playlist: * * spotify:user:napstersean:playlist:3vxotOnOGDlZXyzJPLFnm2 * spotify:playlist:3vxotOnOGDlZXyzJPLFnm2 * * @param baseUri The first URI to compare. * @param refUri The second URI to compare. * @return Whether they shared idenitity */ static isSameIdentity(baseUri: URI | string, refUri: URI | string): boolean; /** * Returns the hex representation of a Base62 encoded id. * * @param id The base62 encoded id. * @return The hex representation of the base62 id. */ static idToHex(id: string): string; /** * Returns the base62 representation of a hex encoded id. * * @param hex The hex encoded id. * @return The base62 representation of the id. */ static hexToId(hex: string): string; /** * Creates a new 'album' type URI. * * @param id The id of the album. * @param disc The disc number of the album. * @return The album URI. */ static albumURI(id: string, disc: number): URI; /** * Creates a new 'application' type URI. * * @param id The id of the application. * @param args An array containing the arguments to the app. * @return The application URI. */ static applicationURI(id: string, args: string[]): URI; /** * Creates a new 'artist' type URI. * * @param id The id of the artist. * @return The artist URI. */ static artistURI(id: string): URI; /** * Creates a new 'collection' type URI. * * @param username The non-canonical username of the rootlist owner. * @param category The category of the collection. * @return The collection URI. */ static collectionURI(username: string, category: string): URI; /** * Creates a new 'collection-album' type URI. * * @param username The non-canonical username of the rootlist owner. * @param id The id of the album. * @return The collection album URI. */ static collectionAlbumURI(username: string, id: string): URI; /** * Creates a new 'collection-artist' type URI. * * @param username The non-canonical username of the rootlist owner. * @param id The id of the artist. * @return The collection artist URI. */ static collectionAlbumURI(username: string, id: string): URI; /** * Creates a new 'concert' type URI. * * @param id The id of the concert. * @return The concert URI. */ static concertURI(id: string): URI; /** * Creates a new 'episode' type URI. * * @param id The id of the episode. * @return The episode URI. */ static episodeURI(id: string): URI; /** * Creates a new 'folder' type URI. * * @param id The id of the folder. * @return The folder URI. */ static folderURI(id: string): URI; /** * Creates a new 'local-album' type URI. * * @param artist The artist of the album. * @param album The name of the album. * @return The local album URI. */ static localAlbumURI(artist: string, album: string): URI; /** * Creates a new 'local-artist' type URI. * * @param artist The name of the artist. * @return The local artist URI. */ static localArtistURI(artist: string): URI; /** * Creates a new 'playlist-v2' type URI. * * @param id The id of the playlist. * @return The playlist URI. */ static playlistV2URI(id: string): URI; /** * Creates a new 'prerelease' type URI. * * @param id The id of the prerelease. * @return The prerelease URI. */ static prereleaseURI(id: string): URI; /** * Creates a new 'profile' type URI. * * @param username The non-canonical username of the rootlist owner. * @param args A list of arguments. * @return The profile URI. */ static profileURI(username: string, args: string[]): URI; /** * Creates a new 'search' type URI. * * @param query The unencoded search query. * @return The search URI */ static searchURI(query: string): URI; /** * Creates a new 'show' type URI. * * @param id The id of the show. * @return The show URI. */ static showURI(id: string): URI; /** * Creates a new 'station' type URI. * * @param args An array of arguments for the station. * @return The station URI. */ static stationURI(args: string[]): URI; /** * Creates a new 'track' type URI. * * @param id The id of the track. * @param anchor The point in the track formatted as mm:ss * @param context An optional context URI * @param play Toggles autoplay * @return The track URI. */ static trackURI(id: string, anchor: string, context?: string, play?: boolean): URI; /** * Creates a new 'user-toplist' type URI. * * @param username The non-canonical username of the toplist owner. * @param toplist The toplist type. * @return The user-toplist URI. */ static userToplistURI(username: string, toplist: string): URI; static isAd(uri: URI | string): boolean; static isAlbum(uri: URI | string): boolean; static isGenre(uri: URI | string): boolean; static isQueue(uri: URI | string): boolean; static isApplication(uri: URI | string): boolean; static isArtist(uri: URI | string): boolean; static isArtistToplist(uri: URI | string): boolean; static isArtistConcerts(uri: URI | string): boolean; static isAudioFile(uri: URI | string): boolean; static isCollection(uri: URI | string): boolean; static isCollectionAlbum(uri: URI | string): boolean; static isCollectionArtist(uri: URI | string): boolean; static isCollectionMissingAlbum(uri: URI | string): boolean; static isCollectionTrackList(uri: URI | string): boolean; static isConcert(uri: URI | string): boolean; static isContextGroup(uri: URI | string): boolean; static isDailyMix(uri: URI | string): boolean; static isEmpty(uri: URI | string): boolean; static isEpisode(uri: URI | string): boolean; static isFacebook(uri: URI | string): boolean; static isFolder(uri: URI | string): boolean; static isFollowers(uri: URI | string): boolean; static isFollowing(uri: URI | string): boolean; static isImage(uri: URI | string): boolean; static isInbox(uri: URI | string): boolean; static isInterruption(uri: URI | string): boolean; static isLibrary(uri: URI | string): boolean; static isLive(uri: URI | string): boolean; static isRoom(uri: URI | string): boolean; static isExpression(uri: URI | string): boolean; static isLocal(uri: URI | string): boolean; static isLocalTrack(uri: URI | string): boolean; static isLocalAlbum(uri: URI | string): boolean; static isLocalArtist(uri: URI | string): boolean; static isMerch(uri: URI | string): boolean; static isMosaic(uri: URI | string): boolean; static isPlaylist(uri: URI | string): boolean; static isPlaylistV2(uri: URI | string): boolean; static isPrerelease(uri: URI | string): boolean; static isProfile(uri: URI | string): boolean; static isPublishedRootlist(uri: URI | string): boolean; static isRadio(uri: URI | string): boolean; static isRootlist(uri: URI | string): boolean; static isSearch(uri: URI | string): boolean; static isShow(uri: URI | string): boolean; static isSocialSession(uri: URI | string): boolean; static isSpecial(uri: URI | string): boolean; static isStarred(uri: URI | string): boolean; static isStation(uri: URI | string): boolean; static isTempPlaylist(uri: URI | string): boolean; static isToplist(uri: URI | string): boolean; static isTrack(uri: URI | string): boolean; static isTrackset(uri: URI | string): boolean; static isUserToplist(uri: URI | string): boolean; static isUserTopTracks(uri: URI | string): boolean; static isUnknown(uri: URI | string): boolean; static isMedia(uri: URI | string): boolean; static isQuestion(uri: URI | string): boolean; static isPoll(uri: URI | string): boolean; static isPlaylistV1OrV2(uri: URI | string): boolean; } /** * Create custom menu item and prepend to right click context menu */ namespace ContextMenu { type OnClickCallback = (uris: string[], uids?: string[], contextUri?: string) => void; type ShouldAddCallback = (uris: string[], uids?: string[], contextUri?: string) => boolean; // Single context menu item class Item { /** * List of valid icons to use. */ static readonly iconList: Icon[]; constructor(name: string, onClick: OnClickCallback, shouldAdd?: ShouldAddCallback, icon?: Icon, disabled?: boolean); name: string; icon: Icon | string; disabled: boolean; /** * A function returning boolean determines whether item should be prepended. */ shouldAdd: ShouldAddCallback; /** * A function to call when item is clicked */ onClick: OnClickCallback; /** * Item is only available in Context Menu when method "register" is called. */ register: () => void; /** * Stop Item to be prepended into Context Menu. */ deregister: () => void; } /** * Create a sub menu to contain `Item`s. * `Item`s in `subItems` array shouldn't be registered. */ class SubMenu { constructor(name: string, subItems: Iterable, shouldAdd?: ShouldAddCallback, disabled?: boolean); name: string; disabled: boolean; /** * A function returning boolean determines whether item should be prepended. */ shouldAdd: ShouldAddCallback; addItem: (item: Item) => void; removeItem: (item: Item) => void; /** * SubMenu is only available in Context Menu when method "register" is called. */ register: () => void; /** * Stop SubMenu to be prepended into Context Menu. */ deregister: () => void; } } /** * Popup Modal */ namespace PopupModal { interface Content { title: string; /** * You can specify a string for simple text display * or a HTML element for interactive config/setting menu, * or a React JSX element for React-based components */ content: string | Element | React.JSX.Element; /** * Bigger window */ isLarge?: boolean; } function display(e: Content): void; function hide(): void; } /** React instance to create components */ const React: any; /** React DOM instance to render and mount components */ const ReactDOM: any; /** React DOM Server instance to render components to string */ const ReactDOMServer: any; /** React JSX runtime instance to transform JSX elements */ const ReactJSX: any; /** Stock React components exposed from Spotify library */ namespace ReactComponent { type ContextMenuProps = { /** * Decide whether to use the global singleton context menu (rendered in ) * or a new inline context menu (rendered in a sibling * element to `children`) */ renderInline?: boolean; /** * Determins what will trigger the context menu. For example, a click, or a right-click */ trigger?: "click" | "right-click"; /** * Determins is the context menu should open or toggle when triggered */ action?: "toggle" | "open"; /** * The preferred placement of the context menu when it opens. * Relative to trigger element. */ placement?: | "top" | "top-start" | "top-end" | "right" | "right-start" | "right-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end"; /** * The x and y offset distances at which the context menu should open. * Relative to trigger element and `position`. */ offset?: [number, number]; /** * Will stop the client from scrolling while the context menu is open */ preventScrollingWhileOpen?: boolean; /** * The menu UI to render inside of the context menu. */ menu: | typeof Spicetify.ReactComponent.Menu | typeof Spicetify.ReactComponent.AlbumMenu | typeof Spicetify.ReactComponent.PodcastShowMenu | typeof Spicetify.ReactComponent.ArtistMenu | typeof Spicetify.ReactComponent.PlaylistMenu; /** * A child of the context menu. Should be ``; const slider = container.querySelector("button.switch"); slider.classList.toggle("disabled", !defaultVal); slider.onclick = () => { const state = slider.classList.contains("disabled"); slider.classList.toggle("disabled"); changeValue(name, state); }; return container; } function createDropdown(name, desc, defaultVal, options) { const container = document.createElement("div"); container.classList.add("setting-row"); container.id = name; container.innerHTML = `
    `; const dropdown = container.querySelector("select"); dropdown.value = defaultVal; dropdown.onchange = () => { changeValue(name, dropdown.value); }; return container; } const searchBar = document.createElement("div"); searchBar.classList.add("setting-row"); searchBar.id = "search"; searchBar.innerHTML = `
    ${Spicetify.SVGIcons.search}
    `; const search = searchBar.querySelector("input.search"); search.oninput = () => { const query = search.value.toLowerCase(); const rows = content.querySelectorAll(".setting-row"); for (const row of rows) { if (row.id === "search" || row.id === "reset") continue; row.style.display = row.textContent.trim().toLowerCase().includes(query) || row.id.toLowerCase().includes(query) ? "flex" : "none"; } }; const resetButton = document.createElement("div"); resetButton.classList.add("setting-row"); resetButton.id = "reset"; resetButton.innerHTML += `
    `; const resetBtn = resetButton.querySelector("button.reset"); resetBtn.onclick = () => { localStorage.removeItem("spicetify-exp-features"); window.location.reload(); }; content.appendChild(searchBar); for (const name of Object.keys(overrideList)) { if (!prevSessionOverrideList.includes(name) && remoteConfiguration.values.has(name)) { const currentValue = remoteConfiguration.values.get(name); overrideList[name].value = currentValue; localStorage.setItem("spicetify-exp-features", JSON.stringify(overrideList)); featureMap[name] = currentValue; setOverrides({ [name]: currentValue }); } const feature = overrideList[name]; if (!overrideList[name]?.description) continue; if (overrideList[name].values) { content.appendChild(createDropdown(name, feature.description, feature.value, feature.values)); } else content.appendChild(createSlider(name, feature.description, feature.value)); featureMap[name] = feature.value; } content.appendChild(resetButton); })(); await new Promise((res) => Spicetify.Events.webpackLoaded.on(res)); new Spicetify.Menu.Item( "Experimental features", false, () => { Spicetify.PopupModal.display({ title: "Experimental features", content, isLarge: true, }); if (!isFallback) return; const closeButton = document.querySelector("body > generic-modal button.main-trackCreditsModal-closeBtn"); const modalOverlay = document.querySelector("body > generic-modal > div"); if (closeButton && modalOverlay) { closeButton.onclick = () => location.reload(); modalOverlay.onclick = (e) => { // If clicked on overlay, also reload if (e.target === modalOverlay) { location.reload(); } }; } }, `` ).register(); })(); ================================================ FILE: jsHelper/homeConfig.js ================================================ SpicetifyHomeConfig = {}; (async () => { // Status enum const NORMAL = 0; const STICKY = 1; const LOWERED = 2; // List of sections' metadata let list; // Store sections' statuses const statusDic = {}; let mounted = false; SpicetifyHomeConfig.arrange = (sections) => { mounted = true; if (list) { return list; } const stickList = (localStorage.getItem("spicetify-home-config:stick") || "").split(","); const lowList = (localStorage.getItem("spicetify-home-config:low") || "").split(","); const stickSections = []; const lowSections = []; for (const uri of stickList) { const index = sections.findIndex((a) => a?.uri === uri || a?.item?.uri === uri); if (index !== -1) { const item = sections[index]; const uri = item.item.uri || item.uri; statusDic[uri] = STICKY; stickSections.push(item); sections[index] = undefined; } } for (const uri of lowList) { const index = sections.findIndex((a) => a?.uri === uri || a?.item?.uri === uri); if (index !== -1) { const item = sections[index]; const uri = item.item.uri || item.uri; statusDic[uri] = LOWERED; lowSections.push(item); sections[index] = undefined; } } list = [...stickSections, ...sections.filter(Boolean), ...lowSections]; return list; }; const up = document.createElement("button"); up.innerText = "Up"; const down = document.createElement("button"); down.innerText = "Down"; const lower = document.createElement("button"); const stick = document.createElement("button"); const sectionStyle = document.createElement("style"); sectionStyle.innerHTML = ` .main-home-content section { order: 0 !important; } `; const containerStyle = document.createElement("style"); containerStyle.innerHTML = ` #spicetify-home-config { position: relative; width: 100%; height: 0; display: flex; justify-content: center; align-items: flex-start; gap: 5px; z-index: 9999; } #spicetify-home-config button { min-width: 60px; height: 40px; border-radius: 3px; background-color: var(--spice-main); color: var(--spice-text); border: 1px solid var(--spice-text); } #spicetify-home-config button:disabled { color: var(--spice-button-disabled); } `; const container = document.createElement("div"); container.id = "spicetify-home-config"; container.append(containerStyle, up, down, lower, stick); document.head.append(sectionStyle); let elem = []; function injectInteraction() { const main = document.querySelector(".main-home-content"); elem = [...main.querySelectorAll("section")]; for (const [index, item] of elem.entries()) { item.dataset.uri = list[index]?.uri ?? list[index].item?.uri; } function appendItems() { const stick = []; const low = []; const normal = []; for (const el of elem) { if (statusDic[el.dataset.uri] === STICKY) stick.push(el); else if (statusDic[el.dataset.uri] === LOWERED) low.push(el); else normal.push(el); } localStorage.setItem( "spicetify-home-config:stick", stick.map((a) => a.dataset.uri) ); localStorage.setItem( "spicetify-home-config:low", low.map((a) => a.dataset.uri) ); elem = [...stick, ...normal, ...low]; main.append(...elem); } function onSwap(item, dir) { container.remove(); const curPos = elem.findIndex((e) => e === item); const newPos = curPos + dir; if (newPos < 0 || newPos > elem.length - 1) return; [elem[curPos], elem[newPos]] = [elem[newPos], elem[curPos]]; [list[curPos], list[newPos]] = [list[newPos], list[curPos]]; appendItems(); } function onChangeStatus(item, status) { container.remove(); const isToggle = statusDic[item.dataset.uri] === status; statusDic[item.dataset.uri] = isToggle ? NORMAL : status; appendItems(); } for (const el of elem) { el.onmouseover = () => { const status = statusDic[el.dataset.uri]; const index = elem.findIndex((a) => a === el); if (!status || index === 0 || status !== statusDic[elem[index - 1]?.dataset.uri]) { up.disabled = true; } else { up.disabled = false; up.onclick = () => onSwap(el, -1); } if (!status || index === elem.length - 1 || status !== statusDic[elem[index + 1]?.dataset.uri]) { down.disabled = true; } else { down.disabled = false; down.onclick = () => onSwap(el, 1); } stick.innerText = status === STICKY ? "Unstick" : "Stick"; lower.innerText = status === LOWERED ? "Unlower" : "Lower"; lower.onclick = () => onChangeStatus(el, LOWERED); stick.onclick = () => onChangeStatus(el, STICKY); el.prepend(container); }; } } function removeInteraction() { container.remove(); for (const a of elem) { a.onmouseover = undefined; } } await new Promise((res) => Spicetify.Events.webpackLoaded.on(res)); SpicetifyHomeConfig.menu = new Spicetify.Menu.Item( "Home config", false, (self) => { self.setState(!self.isEnabled); if (self.isEnabled) { injectInteraction(); } else { removeInteraction(); } }, Spicetify.SVGIcons["grid-view"] ); SpicetifyHomeConfig.addToMenu = () => { SpicetifyHomeConfig.menu.register(); }; SpicetifyHomeConfig.removeMenu = () => { SpicetifyHomeConfig.menu.setState(false); SpicetifyHomeConfig.menu.deregister(); }; await new Promise((res) => Spicetify.Events.platformLoaded.on(res)); // Init if (Spicetify.Platform.History.location.pathname === "/") { SpicetifyHomeConfig.addToMenu(); } Spicetify.Platform.History.listen(({ pathname }) => { if (pathname === "/") { SpicetifyHomeConfig.addToMenu(); } else { SpicetifyHomeConfig.removeMenu(); } }); })(); ================================================ FILE: jsHelper/sidebarConfig.js ================================================ (function SidebarConfig() { const sidebar = document.querySelector(".Root__nav-bar"); if (!sidebar) return setTimeout(SidebarConfig, 100); let isGlobalNavbar = false; // Status enum const HIDDEN = 0; const SHOW = 1; const STICKY = 2; // Store sidebar buttons elements let appItems; let list; let hiddenList; let YLXSidebarState = 0; // Store sidebar buttons let buttons = []; let ordered = []; function arrangeItems(storage) { const newButtons = [...buttons]; const orderedButtons = []; for (const ele of storage) { const index = newButtons.findIndex((a) => ele[0] === a?.dataset.id); if (index !== -1) { orderedButtons.push([newButtons[index], ele[1]]); newButtons[index] = undefined; } } for (const button of newButtons) { if (button) orderedButtons.push([button, SHOW]); } ordered = orderedButtons; } function appendItems() { const toShow = []; const toHide = []; const toStick = []; for (const el of ordered) { const [item, status] = el; if (status === STICKY) { appItems.append(item); toStick.push(el); } else if (status === SHOW) { list.append(item); toShow.push(el); } else { hiddenList.append(item); toHide.push(el); } } ordered = [...toStick, ...toShow, ...toHide]; } function writeStorage() { const array = ordered.map((a) => [a[0].dataset.id, a[1]]); return localStorage.setItem("spicetify-sidebar-config:ylx", JSON.stringify(array)); } const container = document.createElement("div"); container.id = "spicetify-sidebar-config"; const up = document.createElement("button"); up.innerText = "Up"; const down = document.createElement("button"); down.innerText = "Down"; const hide = document.createElement("button"); const stick = document.createElement("button"); const style = document.createElement("style"); style.innerHTML = ` #spicetify-hidden-list { background-color: rgba(var(--spice-rgb-main), .3); } #spicetify-sidebar-config { position: relative; width: 100%; height: 0; display: flex; justify-content: space-evenly; align-items: center; top: -20px; left: 0; } #spicetify-sidebar-config button { min-width: 60px; border-radius: 3px; background-color: var(--spice-main); color: var(--spice-text); border: 1px solid var(--spice-text); } #spicetify-sidebar-config button:disabled { color: var(--spice-button-disabled); } `; container.append(style, up, down, hide, stick); function injectInteraction() { function onSwap(item, dir) { container.remove(); const curPos = ordered.findIndex((e) => e[0] === item); const newPos = curPos + dir; if (newPos < 0 || newPos > ordered.length - 1) return; [ordered[curPos], ordered[newPos]] = [ordered[newPos], ordered[curPos]]; appendItems(); } function onChangeStatus(item, status) { container.remove(); const curPos = ordered.findIndex((e) => e[0] === item); ordered[curPos][1] = ordered[curPos][1] === status ? SHOW : status; appendItems(); } YLXSidebarState = Spicetify.Platform.LocalStorageAPI.getItem("ylx-sidebar-state"); if (YLXSidebarState === 1) document.querySelector(".main-yourLibraryX-collapseButton > button")?.click(); document.documentElement.style.setProperty("--nav-bar-width", "280px"); hiddenList.classList.remove("hidden-visually"); for (const el of ordered) { el[0].onmouseover = () => { const [item, status] = el; const index = ordered.findIndex((a) => a === el); if (index === 0 || ordered[index][1] !== ordered[index - 1][1]) { up.disabled = true; } else { up.disabled = false; up.onclick = () => onSwap(item, -1); } if (index === ordered.length - 1 || ordered[index][1] !== ordered[index + 1][1]) { down.disabled = true; } else { down.disabled = false; down.onclick = () => onSwap(item, 1); } stick.innerText = status === STICKY ? "Unstick" : "Stick"; hide.innerText = status === HIDDEN ? "Unhide" : "Hide"; hide.onclick = () => onChangeStatus(item, HIDDEN); stick.onclick = () => onChangeStatus(item, STICKY); item.append(container); }; } } function removeInteraction() { hiddenList.classList.add("hidden-visually"); container.remove(); for (const a of ordered) { a[0].onmouseover = undefined; } if (YLXSidebarState === 1) document.querySelector(".main-yourLibraryX-collapseButton > button")?.click(); else document.documentElement.style.setProperty( "--nav-bar-width", `${Spicetify.Platform.LocalStorageAPI.getItem( YLXSidebarState === 2 ? "ylx-expanded-state-nav-bar-width" : "ylx-default-state-nav-bar-width" )}px` ); writeStorage(); } (async () => { await new Promise((res) => Spicetify.Events.webpackLoaded.on(res)); while (!Spicetify.Snackbar?.enqueueCustomSnackbar) { await new Promise((resolve) => setTimeout(resolve, 10)); } if (document.querySelector(".Root__globalNav")) { const content = Spicetify.React.createElement("div", { dangerouslySetInnerHTML: { __html: "Sidebar config is not supported when Global Navbar is enabled.
    In your terminal, please run spicetify config sidebar_config 0 command and then re-apply spicetify with spicetify apply.", }, style: { "text-size": "12px", }, }); Spicetify.Snackbar?.enqueueCustomSnackbar("sidebar-config", { keyPrefix: "sidebar-config", autoHideDuration: 7500, children: Spicetify.ReactComponent.Snackbar.wrapper({ children: Spicetify.React.createElement(Spicetify.ReactComponent.Snackbar.simpleLayout, { center: content, children: content, dragMetadata: {}, }), }), }); isGlobalNavbar = true; } if (!isGlobalNavbar) { new Spicetify.Menu.Item( "Sidebar config", false, (self) => { self.setState(!self.isEnabled); if (self.isEnabled) { injectInteraction(); } else { removeInteraction(); } }, `` ).register(); } })(); function initConfig() { const libraryX = document.querySelector(".main-yourLibraryX-navItems"); if (!libraryX) { setTimeout(initConfig, 300); return; } InitSidebarXConfig(); } function InitSidebarXConfig() { // STICKY container const YLXAppItems = document.querySelector(".main-yourLibraryX-navItems"); const libraryItems = document.querySelector(".main-yourLibraryX-library"); if (!YLXAppItems || !libraryItems?.querySelector("ul")) { setTimeout(InitSidebarXConfig, 300); return; } appItems = YLXAppItems; buttons = []; ordered = []; appItems.id = "spicetify-sticky-list"; // SHOW container list = document.createElement("ul"); list.id = "spicetify-show-list"; // HIDDEN container hiddenList = document.createElement("ul"); hiddenList.id = "spicetify-hidden-list"; hiddenList.classList.add("hidden-visually"); const playlistList = libraryItems.querySelector("ul"); playlistList.id = "spicetify-playlist-list"; libraryItems.prepend(list, hiddenList); for (const ele of appItems.children) { ele.dataset.id = ele.querySelector("a").pathname; buttons.push(ele); } let storage = []; try { storage = JSON.parse(localStorage.getItem("spicetify-sidebar-config:ylx")); if (!Array.isArray(storage)) throw ""; } catch { storage = buttons.map((el) => [el.dataset.id, STICKY]); } const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === "childList" && mutation.addedNodes.length) { mutation.addedNodes[0].id = "spicetify-playlist-list"; } } }); observer.observe(playlistList.parentElement, { childList: true }); arrangeItems(storage); appendItems(); } initConfig(); const customButtonStyle = document.createElement("style"); customButtonStyle.innerHTML = ` div.GlueDropTarget.personal-library { padding: 0 8px; } div.GlueDropTarget.personal-library >* { padding: 0 16px; height: 40px; border-radius: 4px; } div.GlueDropTarget.personal-library >*.active { background: var(--spice-card); } .main-rootlist-rootlist { margin-top: 0; } .Root__nav-bar :not(.main-yourLibraryX-entryPoints) > #spicetify-show-list >* { padding: 0 24px 0 8px; } .main-yourLibraryX-entryPoints #spicetify-show-list, .main-yourLibraryX-entryPoints #spicetify-hidden-list { padding: 0 12px; } `; document.head.append(customButtonStyle); })(); ================================================ FILE: jsHelper/spicetifyWrapper.js ================================================ window.Spicetify = { Player: { addEventListener: (type, callback) => { if (!(type in Spicetify.Player.eventListeners)) { Spicetify.Player.eventListeners[type] = []; } Spicetify.Player.eventListeners[type].push(callback); }, dispatchEvent: (event) => { if (!(event.type in Spicetify.Player.eventListeners)) { return true; } const stack = Spicetify.Player.eventListeners[event.type]; for (let i = 0; i < stack.length; i++) { if (typeof stack[i] === "function") { stack[i](event); } } return !event.defaultPrevented; }, eventListeners: {}, seek: (p) => { const duration = p <= 1 ? Math.round(p * Spicetify.Player.origin._state.duration) : p; Spicetify.Player.origin.seekTo(duration); }, getProgress: () => (Spicetify.Player.origin._state.isPaused ? 0 : Date.now() - Spicetify.Player.origin._state.timestamp) + Spicetify.Player.origin._state.positionAsOfTimestamp, getProgressPercent: () => Spicetify.Player.getProgress() / Spicetify.Player.origin._state.duration, getDuration: () => Spicetify.Player.origin._state.duration, setVolume: (v) => { Spicetify.Platform.PlaybackAPI.setVolume(v); }, increaseVolume: () => { Spicetify.Platform.PlaybackAPI.raiseVolume(); }, decreaseVolume: () => { Spicetify.Platform.PlaybackAPI.lowerVolume(); }, getVolume: () => Spicetify.Platform.PlaybackAPI._volume, next: () => { Spicetify.Player.origin.skipToNext(); }, back: () => { Spicetify.Player.origin.skipToPrevious(); }, togglePlay: () => { Spicetify.Player.isPlaying() ? Spicetify.Player.pause() : Spicetify.Player.play(); }, isPlaying: () => !Spicetify.Player.origin._state.isPaused, toggleShuffle: () => { Spicetify.Player.origin.setShuffle(!Spicetify.Player.origin._state.shuffle); }, getShuffle: () => Spicetify.Player.origin._state.shuffle, setShuffle: (b) => { Spicetify.Player.origin.setShuffle(b); }, toggleRepeat: () => { Spicetify.Player.origin.setRepeat((Spicetify.Player.origin._state.repeat + 1) % 3); }, getRepeat: () => Spicetify.Player.origin._state.repeat, setRepeat: (r) => { Spicetify.Player.origin.setRepeat(r); }, getMute: () => Spicetify.Player.getVolume() === 0, toggleMute: () => { Spicetify.Player.setMute(!Spicetify.Player.getMute()); }, setMute: (b) => { if (b !== Spicetify.Player.getMute()) { document.querySelector(".volume-bar__icon-button")?.click(); } }, formatTime: (ms) => { let seconds = Math.floor(ms / 1e3); const minutes = Math.floor(seconds / 60); seconds -= minutes * 60; return `${minutes}:${seconds > 9 ? "" : "0"}${String(seconds)}`; }, getHeart: () => Spicetify.Player.origin._state.item.metadata["collection.in_collection"] === "true", pause: () => { Spicetify.Player.origin.pause(); }, play: () => { Spicetify.Player.origin.resume(); }, playUri: async (uri, context = {}, options = {}) => { return await Spicetify.Player.origin.play({ uri: uri }, context, options); }, removeEventListener: (type, callback) => { if (!(type in Spicetify.Player.eventListeners)) return; const stack = Spicetify.Player.eventListeners[type]; for (let i = 0; i < stack.length; i++) { if (stack[i] === callback) { stack.splice(i, 1); return; } } }, skipBack: (amount = 15e3) => { Spicetify.Player.origin.seekBackward(amount); }, skipForward: (amount = 15e3) => { Spicetify.Player.origin.seekForward(amount); }, setHeart: (b) => { const uris = [Spicetify.Player.origin._state.item.uri]; if (b) { Spicetify.Platform.LibraryAPI.add({ uris }); } else { Spicetify.Platform.LibraryAPI.remove({ uris }); } }, toggleHeart: () => { Spicetify.Player.setHeart(!Spicetify.Player.getHeart()); }, }, test: () => { function checkObject(object) { const { objectToCheck, methods, name } = object; let count = methods.size; for (const method of methods) { if (objectToCheck[method] === undefined || objectToCheck[method] === null) { console.error(`${name}.${method} is not available. Please open an issue in the Spicetify repository to inform us about it.`); count--; } } console.log(`${count}/${methods.size} ${name} methods and objects are OK.`); for (const key of Object.keys(objectToCheck)) { if (!methods.has(key)) { console.warn(`${name} method ${key} exists but is not in the method list. Consider adding it.`); } } } const objectsToCheck = new Set([ { objectToCheck: Spicetify, name: "Spicetify", methods: new Set([ "Player", "addToQueue", "CosmosAsync", "getAudioData", "Keyboard", "URI", "LocalStorage", "Queue", "removeFromQueue", "showNotification", "Menu", "ContextMenu", "React", "Mousetrap", "Locale", "ReactDOM", "Topbar", "ReactComponent", "PopupModal", "SVGIcons", "colorExtractor", "test", "Platform", "_platform", "Config", "expFeatureOverride", "createInternalMap", "RemoteConfigResolver", "Playbar", "Tippy", "_getStyledClassName", "GraphQL", "ReactHook", "AppTitle", "_reservedPanelIds", "ReactFlipToolkit", "classnames", "ReactQuery", "Color", "extractColorPreset", "ReactDOMServer", "Snackbar", "ContextMenuV2", "ReactJSX", "_renderNavLinks", "Events", ]), }, { objectToCheck: Spicetify.Player, name: "Spicetify.Player", methods: new Set([ "addEventListener", "back", "data", "decreaseVolume", "dispatchEvent", "eventListeners", "formatTime", "getDuration", "getHeart", "getMute", "getProgress", "getProgressPercent", "getRepeat", "getShuffle", "getVolume", "increaseVolume", "isPlaying", "next", "pause", "play", "removeEventListener", "seek", "setMute", "setRepeat", "setShuffle", "setVolume", "skipBack", "skipForward", "toggleHeart", "toggleMute", "togglePlay", "toggleRepeat", "toggleShuffle", "origin", "playUri", "setHeart", ]), }, { objectToCheck: Spicetify.ReactComponent, name: "Spicetify.ReactComponent", methods: new Set([ "RightClickMenu", "ContextMenu", "Menu", "MenuItem", "AlbumMenu", "PodcastShowMenu", "ArtistMenu", "PlaylistMenu", "TrackMenu", "TooltipWrapper", "TextComponent", "IconComponent", "ConfirmDialog", "Slider", "RemoteConfigProvider", "ButtonPrimary", "ButtonSecondary", "ButtonTertiary", "Snackbar", "Chip", "Toggle", "Cards", "Router", "Routes", "Route", "StoreProvider", "PlatformProvider", "Dropdown", "MenuSubMenuItem", "Navigation", "ScrollableContainer", ]), }, { objectToCheck: Spicetify.ReactComponent.Cards, name: "Spicetify.ReactComponent.Cards", methods: new Set([ "Default", "Hero", "CardImage", "Album", "Artist", "Audiobook", "Episode", "Playlist", "Profile", "Show", "Track", "FeatureCard", ]), }, { objectToCheck: Spicetify.ReactHook, name: "Spicetify.ReactHook", methods: new Set(["DragHandler", "useExtractedColor"]), }, ]); for (const object of objectsToCheck) { checkObject(object); } }, GraphQL: { Definitions: {}, }, ReactComponent: {}, ReactHook: {}, ReactFlipToolkit: {}, Snackbar: {}, Platform: {}, }; (function waitForPlatform() { if (!Spicetify._platform) { setTimeout(waitForPlatform, 50); return; } const { _platform } = Spicetify; for (const key of Object.keys(_platform)) { if (key.startsWith("get") && typeof _platform[key] === "function") { Spicetify.Platform[key.slice(3)] = _platform[key](); } else { Spicetify.Platform[key] = _platform[key]; } } })(); (function addMissingPlatformAPIs() { if (!Spicetify.Platform?.version && !Spicetify.Platform?.Registry) { setTimeout(addMissingPlatformAPIs, 50); return; } const os = Spicetify.Platform.operatingSystem; const version = Spicetify.Platform.version.split(".").map((i) => Number.parseInt(i, 10)); if (version[0] === 1 && version[1] === 2 && version[2] < 38) return; for (const [key, _] of Spicetify.Platform.Registry._map.entries()) { if (typeof key?.description !== "string" || !key?.description.endsWith("API")) continue; const symbolName = key.description; if (symbolName === "ExclusiveModeAPI" && os === "Linux") continue; if (Object.hasOwn(Spicetify.Platform, symbolName)) continue; try { const resolvedAPI = Spicetify.Platform.Registry.resolve(key); Spicetify.Platform[symbolName] = resolvedAPI; console.debug(`[spicetifyWrapper] Resolved PlatformAPI from Registry: ${symbolName}`); } catch (err) { console.error(`[spicetifyWrapper] Error resolving PlatformAPI from Registry: ${symbolName}`, err); } } })(); // Based on https://blog.aziz.tn/2025/01/spotify-fix-lagging-issue-on-scrolling.html function applyScrollingFix() { if (!Spicetify.Platform?.version) { setTimeout(applyScrollingFix, 50); return; } // Run only for 1.2.56 and lower const version = Spicetify.Platform.version.split(".").map((i) => Number.parseInt(i, 10)); if (version[1] >= 2 && version[2] >= 57) return; const scrollableElements = Array.from(document.querySelectorAll("*")).filter((el) => { if ( el.id === "context-menu" || el.closest("#context-menu") || el.getAttribute("role") === "dialog" || el.classList.contains("popup") || el.getAttribute("aria-haspopup") === "true" ) return false; const style = window.getComputedStyle(el); return style.overflow === "auto" || style.overflow === "scroll" || style.overflowY === "auto" || style.overflowY === "scroll"; }); for (const el of scrollableElements) { if (!el.hasAttribute("data-scroll-optimized")) { el.style.willChange = "transform"; el.style.transform = "translate3d(0, 0, 0)"; el.setAttribute("data-scroll-optimized", "true"); } } } const observer = new MutationObserver(applyScrollingFix); observer.observe(document.body, { childList: true, subtree: true, attributes: false, }); const originalPushState = history.pushState; history.pushState = function (...args) { originalPushState.apply(this, args); setTimeout(applyScrollingFix, 100); }; window.addEventListener("popstate", () => { setTimeout(applyScrollingFix, 100); }); applyScrollingFix(); (async function addProxyCosmos() { if (!Spicetify.Player.origin?._cosmos && !Spicetify.Platform?.Registry) { setTimeout(addProxyCosmos, 50); return; } const _cosmos = Spicetify.Player.origin?._cosmos ?? Spicetify.Platform?.Registry.resolve(Symbol.for("Cosmos")); const allowedMethodsMap = { get: "get", post: "post", del: "delete", put: "put", patch: "patch", }; const allowedMethodsSet = new Set(Object.keys(allowedMethodsMap)); const internalEndpoints = new Set(["sp:", "wg:"]); const handler = { get: (target, prop, receiver) => { const internalFetch = Reflect.get(target, prop, receiver); if (typeof internalFetch !== "function" || !allowedMethodsSet.has(prop)) return internalFetch; const version = Spicetify.Platform.version.split(".").map((i) => Number.parseInt(i)); if (version[1] >= 2 && version[2] < 31) return internalFetch; return async function (url, body) { const urlObj = new URL(url); const corsProxyURLTemplate = window.localStorage.getItem("spicetify:corsProxyTemplate") ?? "https://cors-proxy.spicetify.app/{url}"; const isWebAPI = urlObj.hostname === "api.spotify.com"; const isSpClientAPI = urlObj.hostname.includes("spotify.com") && urlObj.hostname.includes("spclient"); const isInternalURL = internalEndpoints.has(urlObj.protocol); if (isInternalURL) return internalFetch.apply(this, [url, body]); const shouldUseCORSProxy = !isWebAPI && !isSpClientAPI && !isInternalURL; const method = allowedMethodsMap[prop.toLowerCase()]; const headers = { "Content-Type": "application/json", }; const options = { method, headers, timeout: 1000 * 15, }; let finalURL = urlObj.toString(); if (body) { if (method === "get") { const params = new URLSearchParams(body); const useSeparator = shouldUseCORSProxy && new URL(finalURL).search.startsWith("?"); finalURL += `${useSeparator ? "&" : "?"}${params.toString()}`; } else options.body = !Array.isArray(body) && typeof body === "object" ? JSON.stringify(body) : body; } if (shouldUseCORSProxy) { finalURL = corsProxyURLTemplate.replace(/{url}/, finalURL); try { new URL(finalURL); } catch { console.error("[spicetifyWrapper] Invalid CORS Proxy URL template"); } } const Authorization = `Bearer ${Spicetify.Platform.AuthorizationAPI.getState().token.accessToken}`; let injectedHeaders = {}; if (isWebAPI) injectedHeaders = { Authorization }; if (isSpClientAPI) { injectedHeaders = { Authorization, "Spotify-App-Version": Spicetify.Platform.version, "App-Platform": Spicetify.Platform.PlatformData.app_platform, }; } Object.assign(options.headers, injectedHeaders); try { return fetch(finalURL, options).then((res) => { if (!res.ok) return { code: res.status, error: res.statusText, message: "Failed to fetch", stack: undefined }; try { return res.clone().json(); } catch { try { return res.clone().blob(); } catch { return res.clone().text(); } } }); } catch (e) { console.error(e); } }; }, }; while (!Spicetify.Player.origin) await new Promise((r) => setTimeout(r, 50)); Spicetify.Player.origin._cosmos = new Proxy(_cosmos, handler); Object.defineProperty(Spicetify, "CosmosAsync", { get: () => { return Spicetify.Player.origin?._cosmos; }, }); })(); const fnStr = (f) => { try { return f.toString(); } catch { try { return Function.prototype.toString.call(f); } catch { return ""; } } }; (async function hotloadWebpackModules() { while (!window?.webpackChunkclient_web) { await new Promise((r) => setTimeout(r, 50)); } // Force all webpack modules to load const require = webpackChunkclient_web.push([[Symbol()], {}, (re) => re]); while (!require.m) await new Promise((r) => setTimeout(r, 50)); console.log("[spicetifyWrapper] Waiting for required webpack modules to load"); let webpackDidCallback = false; // https://github.com/webpack/webpack/blob/main/lib/runtime/OnChunksLoadedRuntimeModule.js require.O( null, [], () => { webpackDidCallback = true; }, 1 ); let chunks = Object.entries(require.m); let cache = Object.keys(require.m).map((id) => require(id)); // For _renderNavLinks to work Spicetify.React = cache.find((m) => m?.useMemo); while (!webpackDidCallback) { await new Promise((r) => setTimeout(r, 100)); } console.log("[spicetifyWrapper] All required webpack modules loaded"); chunks = Object.entries(require.m); cache = Object.keys(require.m).map((id) => require(id)); Spicetify.Events.platformLoaded.fire(); const modules = cache .filter((module) => typeof module === "object") .flatMap((module) => { try { return Object.values(module); } catch {} }); // polyfill for chromium <117 const groupBy = (values, keyFinder) => { if (typeof Object.groupBy === "function") return Object.groupBy(values, keyFinder); return values.reduce((a, b) => { const key = typeof keyFinder === "function" ? keyFinder(b) : b[keyFinder]; a[key] = a[key] ? [...a[key], b] : [b]; return a; }, {}); }; const webpackFactories = new Set(Object.values(require.m)); const functionModules = modules.flatMap((module) => typeof module === "function" ? [module] : typeof module === "object" && module ? Object.values(module).filter((v) => typeof v === "function" && !webpackFactories.has(v)) : [] ); const exportedReactObjects = groupBy(modules.filter(Boolean), (x) => x.$$typeof); const exportedMemos = exportedReactObjects[Symbol.for("react.memo")]; const exportedForwardRefs = exportedReactObjects[Symbol.for("react.forward_ref")]; const exportedMemoFRefs = exportedMemos.filter((m) => m.type.$$typeof === Symbol.for("react.forward_ref")); const exposeReactComponentsUI = ({ modules, functionModules, exportedForwardRefs }) => { const componentNames = Object.keys(modules.filter(Boolean).find((e) => e.BrowserDefaultFocusStyleProvider)); const componentRegexes = componentNames.map((n) => new RegExp(`"data-encore-id":(?:[a-zA-Z_$][w$]*\\.){2}${n}\\b`)); const componentPairs = [functionModules.map((f) => [f, f]), exportedForwardRefs.map((f) => [f.render, f])] .flat() .map(([s, f]) => [componentNames.find((_, i) => fnStr(s)?.match(componentRegexes[i])), f]); return Object.fromEntries(componentPairs); }; const reactComponentsUI = exposeReactComponentsUI({ modules, functionModules, exportedForwardRefs }); const knownMenuTypes = ["album", "show", "artist", "track", "playlist"]; const menus = modules .map((m) => { const valueMatch = (m?.type ? fnStr(m.type) : "").match(/value:"([\w-]+)"/); if (valueMatch) return [m, valueMatch[1]]; const typeMatch = (m?.type ? fnStr(m.type) : "").match(/type:[\w$]+\.[\w$]+\.([A-Z_]+)/); if (typeMatch) return [m, typeMatch[1].toLowerCase()]; return null; }) .filter(Boolean) .filter((m) => m[1] !== 'value:"row"') .map(([module, type]) => { type = type.match(/value:"([\w-]+)"/)?.[1] ?? type; if (!knownMenuTypes.includes(type)) return; if (type === "show") type = "podcast-show"; type = `${type .split("-") .map((str) => str[0].toUpperCase() + str.slice(1)) .join("")}Menu`; return [type, module]; }) .filter(Boolean); const cardTypesToFind = ["album", "artist", "audiobook", "episode", "playlist", "profile", "show", "track"]; const cards = [ ...functionModules .flatMap((m) => { return cardTypesToFind.map((type) => { if (fnStr(m).includes(`featureIdentifier:"${type}"`)) { cardTypesToFind.splice(cardTypesToFind.indexOf(type), 1); return [type[0].toUpperCase() + type.slice(1), m]; } }); }) .filter(Boolean), ...modules .flatMap((m) => { return cardTypesToFind.map((type) => { try { if ((m?.type ? fnStr(m.type) : "").includes(`featureIdentifier:"${type}"`)) { cardTypesToFind.splice(cardTypesToFind.indexOf(type), 1); return [type[0].toUpperCase() + type.slice(1), m]; } } catch {} }); }) .filter(Boolean), ]; const _ScrollableContainer = (() => { const SHOW_BUTTONS = { NEVER: "never", ALWAYS: "always", ON_HOVER: "on-hover" }; const SCROLLING_METHOD = { BY_RATIO: "by-ratio", SNAP: "snap" }; const EDGE_GRADIENTS = { NONE: "none", MASK: "mask", LINEAR_GRADIENT: "linear-gradient" }; const DIRECTION = { START: -1, END: 1 }; const CHEVRON_LEFT = ''; const CHEVRON_RIGHT = ''; let stylesInjected = false; function injectStyles() { if (stylesInjected) return; stylesInjected = true; const style = document.createElement("style"); style.className = "spicetify-scrollable-container"; style.textContent = ` .spicetify-sc-contentArea { overflow: hidden; position: relative; } .spicetify-sc-scroller { display: flex; align-items: center; overflow-x: auto; scrollbar-width: none; white-space: nowrap; width: 100%; -ms-overflow-style: none; overscroll-behavior-x: contain; will-change: transform; } @media (prefers-reduced-motion: no-preference) { .spicetify-sc-scroller { scroll-behavior: smooth; } } .spicetify-sc-scroller::-webkit-scrollbar { display: none; } .spicetify-sc-scroller.spicetify-sc-snap { scroll-snap-type: inline mandatory; } .spicetify-sc-scroller.spicetify-sc-snap .spicetify-sc-snapCenter [data-carousel-item] { scroll-snap-align: center; } .spicetify-sc-scroller.spicetify-sc-snap .spicetify-sc-snapStart [data-carousel-item] { scroll-snap-align: start; } .spicetify-sc-scroller.spicetify-sc-wheelEnabled { overscroll-behavior: contain; } .spicetify-sc-scroller.spicetify-sc-maskGradient { --sc-start-color: #000; --sc-end-color: #000; -webkit-mask-composite: source-in, xor; mask-composite: intersect; -webkit-mask-image: linear-gradient(90deg, var(--sc-start-color) 0, #000 120px), linear-gradient(90deg, #000 calc(100% - 120px), var(--sc-end-color) 100%); mask-image: linear-gradient(90deg, var(--sc-start-color) 0, #000 120px), linear-gradient(90deg, #000 calc(100% - 120px), var(--sc-end-color) 100%); -webkit-mask-size: 100% 100%; mask-size: 100% 100%; } .spicetify-sc-scroller.spicetify-sc-maskGradient.spicetify-sc-maskStart { --sc-start-color: transparent; } .spicetify-sc-scroller.spicetify-sc-maskGradient.spicetify-sc-maskEnd { --sc-end-color: transparent; } .spicetify-sc-linearGradient::before, .spicetify-sc-linearGradient::after { bottom: 0; content: ""; height: 100%; opacity: 0; pointer-events: none; position: absolute; top: 0; transition: opacity .15s ease-out; width: 120px; z-index: 2; } .spicetify-sc-linearGradient::before { background: linear-gradient(90deg, var(--carousel-start-chevron-gradient, var(--spice-main)) 0, transparent 100%); inset-inline-start: 0; } .spicetify-sc-linearGradient::after { background: linear-gradient(-90deg, var(--carousel-end-chevron-gradient, var(--spice-main)) 0, transparent 100%); inset-inline-end: 0; } .spicetify-sc-linearGradient.spicetify-sc-lgStart::before { opacity: 1; } .spicetify-sc-linearGradient.spicetify-sc-lgEnd::after { opacity: 1; } .spicetify-sc-carousel { bottom: 0; left: 0; position: absolute; right: 0; top: 0; justify-content: space-between; align-items: center; display: flex; pointer-events: none; } .spicetify-sc-chevronBtn { display: flex; border: none; border-radius: 50%; cursor: pointer; justify-content: center; align-items: center; backdrop-filter: var(--chevrons-button-backdrop-filter, none); background: transparent; background-color: var(--chevrons-button-color, var(--background-elevated-base)); height: 24px; opacity: 0; position: relative; transition: color .15s ease-out, opacity .15s ease-out, background-color .15s ease-out, translate .15s ease-out; translate: 0; width: 24px; z-index: 3; pointer-events: none; color: var(--text-base, #fff); } .spicetify-sc-chevronBtn > * { opacity: .7; z-index: 2; } .spicetify-sc-chevronBtn:hover { background-color: var(--chevrons-button-hover-color, var(--background-elevated-highlight)); } .spicetify-sc-chevronBtn:hover > * { opacity: 1; } .spicetify-sc-chevronBtn.spicetify-sc-chevronVisible { opacity: 1; pointer-events: auto; } .spicetify-sc-onHover .spicetify-sc-chevronBtn { opacity: 0; } .spicetify-sc-contentArea:hover .spicetify-sc-onHover .spicetify-sc-chevronBtn.spicetify-sc-chevronVisible { opacity: 1; } .spicetify-sc-contentArea:hover .spicetify-sc-onHover .spicetify-sc-chevronStart.spicetify-sc-chevronVisible { translate: 8px; } .spicetify-sc-contentArea:hover .spicetify-sc-onHover .spicetify-sc-chevronEnd.spicetify-sc-chevronVisible { translate: -8px; } body[data-dragging-uri-type] .spicetify-sc-chevronBtn { pointer-events: none; }`; document.head.appendChild(style); } function useDragToScroll({ isDisabled = true } = {}) { const { useRef, useCallback } = Spicetify.React; const frameRef = useRef(0); const savedBehavior = useRef(null); const savedSnapType = useRef(null); return useCallback( isDisabled ? () => {} : ({ currentTarget, clientX }) => { if (!(currentTarget instanceof HTMLElement)) return; const el = currentTarget; const restore = () => { el.style.removeProperty("user-select"); if (savedBehavior.current !== null) el.style.scrollBehavior = savedBehavior.current; if (savedSnapType.current !== null) el.style.scrollSnapType = savedSnapType.current; }; const fullCleanup = () => { cancelAnimationFrame(frameRef.current); restore(); }; fullCleanup(); const computed = window.getComputedStyle(el); savedBehavior.current = computed.scrollBehavior; savedSnapType.current = computed.scrollSnapType; el.style.userSelect = "none"; el.style.scrollBehavior = "auto"; el.style.scrollSnapType = "none"; let dragged = false; const startScroll = el.scrollLeft; const startX = clientX; let velocity = 0; const coast = () => { el.scrollLeft += velocity; velocity *= 0.95; if (Math.abs(velocity) > 0.5) frameRef.current = requestAnimationFrame(coast); else fullCleanup(); }; const onMove = (e) => { const dx = e.clientX - startX; if (Math.abs(dx) > 10) dragged = true; const prev = el.scrollLeft; el.scrollLeft = startScroll - dx; velocity = el.scrollLeft - prev; }; document.addEventListener("mousemove", onMove); document.addEventListener( "mouseup", () => { if (dragged) { const block = (e) => { e.preventDefault(); e.stopImmediatePropagation(); }; el.addEventListener("click", block, { once: true, capture: true }); setTimeout(() => el.removeEventListener("click", block, { capture: true })); } document.removeEventListener("mousemove", onMove); cancelAnimationFrame(frameRef.current); frameRef.current = requestAnimationFrame(coast); document.addEventListener("wheel", fullCleanup, { once: true }); }, { once: true } ); }, [isDisabled] ); } function useWheelScroll(onlyHorizontalWheel) { const { useRef, useCallback } = Spicetify.React; const isFirst = useRef(true); const savedBehavior = useRef(null); const timer = useRef(null); return useCallback( (e) => { if (!e.deltaY) return; if (onlyHorizontalWheel && Math.abs(e.deltaY) > Math.abs(e.deltaX)) return; const el = e.currentTarget; if (isFirst.current) { isFirst.current = false; savedBehavior.current = el.style.scrollBehavior; el.style.scrollBehavior = "auto"; } el.scrollLeft += e.deltaY + e.deltaX; clearTimeout(timer.current); timer.current = setTimeout(() => { isFirst.current = true; el.style.scrollBehavior = savedBehavior.current ?? ""; }, 100); }, [onlyHorizontalWheel] ); } function useScrollState(scrollerRef, contentRef) { const { useState, useCallback, useEffect } = Spicetify.React; const [canGoStart, setCanGoStart] = useState(false); const [canGoEnd, setCanGoEnd] = useState(false); const update = useCallback(() => { const el = scrollerRef.current; const child = contentRef.current; if (!el || !child) return; const maxScroll = el.scrollWidth - el.clientWidth; const pos = Math.abs(el.scrollLeft); const rounded = pos < 1 ? Math.floor(pos) : Math.ceil(pos); const overflows = child.offsetWidth > el.clientWidth; setCanGoStart(overflows && rounded !== 0); setCanGoEnd(overflows && rounded < maxScroll); }, [scrollerRef, contentRef]); useEffect(() => { const el = scrollerRef.current; const child = contentRef.current; if (!el || !child) return; update(); el.addEventListener("scroll", update); const ro = new ResizeObserver(update); ro.observe(el); ro.observe(child); return () => { el.removeEventListener("scroll", update); ro.disconnect(); }; }, [update, scrollerRef, contentRef]); return { canGoStart, canGoEnd }; } function ScrollableContainerComponent(props) { const { useRef, useCallback, useMemo } = Spicetify.React; const h = Spicetify.ReactJSX.jsx; const hsf = Spicetify.ReactJSX.jsxs; const cn = Spicetify.classnames; const { children, className, chevronsClassName, showButtons = SHOW_BUTTONS.ALWAYS, ariaLabel, onlyHorizontalWheel = false, wheelScrollEnabled = true, scrollContentClassName, scrollerClassName, scrollRatio = 0.9, scrollingMethod = SCROLLING_METHOD.BY_RATIO, scrollPadding, scrollSnapAlign, scrollSnapByItems = 1, edgeGradients = EDGE_GRADIENTS.MASK, dragToScrollOptions = { isDisabled: true }, onScroll, activeElementThreshold = 10, onNavigationClick, role = "list", } = props; injectStyles(); const scrollerRef = useRef(null); const contentRef = useRef(null); const lastIndex = useRef(-1); const { canGoStart, canGoEnd } = useScrollState(scrollerRef, contentRef); const dragHandler = useDragToScroll(dragToScrollOptions); const wheelHandler = useWheelScroll(onlyHorizontalWheel); const isRtl = useMemo(() => document.documentElement.dir === "rtl", []); const getActiveIndex = useCallback(() => { const scrollPos = Math.abs(scrollerRef.current?.scrollLeft ?? 0); let index = -1; if (contentRef.current?.children) { let idx = -1; for (const child of contentRef.current.children) { if (child instanceof HTMLElement) { idx++; if (Math.abs(child.offsetLeft - scrollPos) <= child.offsetWidth / activeElementThreshold) index = idx; } } } return index; }, [activeElementThreshold]); const fireScroll = useCallback(() => { if (!onScroll) return; const index = getActiveIndex(); if (lastIndex.current !== index) { lastIndex.current = index; onScroll(index); } }, [getActiveIndex, onScroll]); const navigate = useCallback( (direction) => { if (!scrollerRef.current) return; const dir = isRtl ? -1 : 1; if (scrollingMethod === SCROLLING_METHOD.SNAP) { const item = contentRef.current?.querySelector("[data-carousel-item]"); if (!item) return; scrollerRef.current.scrollBy({ left: dir * scrollSnapByItems * item.getBoundingClientRect().width * direction }); } else scrollerRef.current.scrollBy({ left: dir * direction * scrollerRef.current.clientWidth * scrollRatio }); fireScroll(); onNavigationClick?.(direction); }, [scrollingMethod, fireScroll, isRtl, scrollSnapByItems, scrollRatio, onNavigationClick] ); const isSnap = scrollingMethod === SCROLLING_METHOD.SNAP; const isMask = edgeGradients === EDGE_GRADIENTS.MASK; const isLinearGradient = edgeGradients === EDGE_GRADIENTS.LINEAR_GRADIENT; const makeChevron = (svgPath, position, visible, dir) => h("div", { className: cn("spicetify-sc-chevronBtn", `spicetify-sc-chevron${position}`, { "spicetify-sc-chevronVisible": visible }), onClick: (e) => { e.preventDefault(); e.stopPropagation(); navigate(dir); }, "aria-hidden": "true", children: h("svg", { height: 12, width: 12, viewBox: "0 0 16 16", fill: "currentColor", dangerouslySetInnerHTML: { __html: svgPath } }), }); return hsf("div", { className: cn("spicetify-sc-contentArea", className, { "spicetify-sc-linearGradient": isLinearGradient, "spicetify-sc-lgStart": isLinearGradient && canGoStart, "spicetify-sc-lgEnd": isLinearGradient && canGoEnd, }), children: [ h("div", { ref: scrollerRef, className: cn("spicetify-sc-scroller", scrollerClassName, { "spicetify-sc-snap": isSnap, "spicetify-sc-maskGradient": isMask, "spicetify-sc-wheelEnabled": wheelScrollEnabled, "spicetify-sc-maskStart": isMask && canGoStart, "spicetify-sc-maskEnd": isMask && canGoEnd, }), onScroll: onScroll ? fireScroll : undefined, onMouseDownCapture: dragHandler, onWheel: wheelScrollEnabled ? wheelHandler : undefined, role, "aria-label": ariaLabel, style: isSnap ? { scrollPadding } : undefined, children: h("div", { ref: contentRef, role: "presentation", className: cn(scrollContentClassName, { "spicetify-sc-snapStart": scrollSnapAlign === "start", "spicetify-sc-snapCenter": scrollSnapAlign === "center", }), children, }), }), showButtons !== SHOW_BUTTONS.NEVER && hsf("div", { className: cn("spicetify-sc-carousel", chevronsClassName, { "spicetify-sc-onHover": showButtons === SHOW_BUTTONS.ON_HOVER, }), children: [makeChevron(CHEVRON_LEFT, "Start", canGoStart, DIRECTION.START), makeChevron(CHEVRON_RIGHT, "End", canGoEnd, DIRECTION.END)], }), ], }); } ScrollableContainerComponent.SHOW_BUTTONS = SHOW_BUTTONS; ScrollableContainerComponent.SCROLLING_METHOD = SCROLLING_METHOD; ScrollableContainerComponent.EDGE_GRADIENTS = EDGE_GRADIENTS; ScrollableContainerComponent.DIRECTION = DIRECTION; return ScrollableContainerComponent; })(); Object.assign(Spicetify, { React: cache.find((m) => m?.useMemo), ReactJSX: cache.find((m) => m?.jsx), ReactDOM: cache.find((m) => m?.createPortal), ReactDOMServer: cache.find((m) => m?.renderToString), // https://github.com/JedWatson/classnames/ classnames: chunks .filter(([_, v]) => v.toString().includes("[native code]")) .map(([i]) => require(i)) .find((e) => typeof e === "function"), Color: functionModules.find((m) => fnStr(m).includes("static fromHex") || fnStr(m).includes("this.rgb")), Player: { ...Spicetify.Player, get origin() { return Spicetify.Platform?.PlayerAPI; }, }, GraphQL: { ...Spicetify.GraphQL, get Request() { return Spicetify.Platform?.GraphQLLoader || Spicetify.GraphQL.Handler?.(Spicetify.GraphQL.Context); }, Context: functionModules.find((m) => fnStr(m).includes("subscription") && fnStr(m).includes("mutation")), Handler: functionModules.find((m) => fnStr(m).includes("GraphQL subscriptions are not supported")), }, ReactComponent: { ...Spicetify.ReactComponent, TextComponent: modules.find((m) => m?.h1 && m?.render), Menu: functionModules.find((m) => fnStr(m).includes("getInitialFocusElement") && fnStr(m).includes("children")), MenuItem: functionModules.find((m) => fnStr(m).includes("handleMouseEnter") && fnStr(m).includes("onClick")), MenuSubMenuItem: functionModules.find((f) => fnStr(f).includes("subMenuIcon")), Slider: wrapProvider(functionModules.find((m) => fnStr(m).includes("progressBarRef"))), RemoteConfigProvider: functionModules.find((m) => fnStr(m).includes("resolveSuspense") && fnStr(m).includes("configuration")), RightClickMenu: functionModules.find( (m) => fnStr(m).includes("action") && fnStr(m).includes("open") && fnStr(m).includes("trigger") && fnStr(m).includes("right-click") ), TooltipWrapper: functionModules.find((m) => fnStr(m).includes("renderInline") && fnStr(m).includes("showDelay")), ButtonPrimary: reactComponentsUI.ButtonPrimary, ButtonSecondary: reactComponentsUI.ButtonSecondary, ButtonTertiary: reactComponentsUI.ButtonTertiary, Snackbar: { wrapper: functionModules.find((m) => fnStr(m).includes("encore-light-theme") && fnStr(m).includes("elevated")), simpleLayout: functionModules.find((m) => ["leading", "center", "trailing"].every((keyword) => fnStr(m).includes(keyword))), ctaText: functionModules.find((m) => fnStr(m).includes("ctaText")), styledImage: functionModules.find((m) => fnStr(m).includes("placeholderSrc")), }, Chip: reactComponentsUI.Chip, Toggle: functionModules.find((m) => fnStr(m).includes("onSelected") && fnStr(m).includes('type:"checkbox"')), Cards: { Default: reactComponentsUI.Card, FeatureCard: functionModules.find( (m) => fnStr(m).includes("?highlight") && fnStr(m).includes("headerText") && fnStr(m).includes("imageContainer") ), Hero: functionModules.find((m) => fnStr(m).includes('"herocard-click-handler"')), CardImage: functionModules.find( (m) => fnStr(m).includes("isHero") && (fnStr(m).includes("withWaves") || fnStr(m).includes("isCircular")) && fnStr(m).includes("imageWrapper") ), ...Object.fromEntries(cards), }, Router: functionModules.find((m) => fnStr(m).includes("navigationType") && fnStr(m).includes("static")), Routes: functionModules.find((m) => fnStr(m).match(/\([\w$]+\)\{let\{children:[\w$]+,location:[\w$]+\}=[\w$]+/)), Route: functionModules.find((m) => fnStr(m).match(/^function [\w$]+\([\w$]+\)\{\(0,[\w$]+\.[\w$]+\)\(!1\)\}$/)), StoreProvider: functionModules.find((m) => fnStr(m).includes("notifyNestedSubs") && fnStr(m).includes("serverState")), ScrollableContainer: _ScrollableContainer, IconComponent: reactComponentsUI.Icon, ...Object.fromEntries(menus), }, ReactHook: { DragHandler: functionModules.find((m) => fnStr(m).includes("dataTransfer") && fnStr(m).includes("data-dragging")), useExtractedColor: functionModules.find( (m) => fnStr(m).includes("extracted-color") || (fnStr(m).includes("colorRaw") && fnStr(m).includes("useEffect")) ), }, // React Query // https://github.com/TanStack/query // v3 until Spotify v1.2.29 // v5 since Spotify v1.2.30 ReactQuery: cache.find((module) => module.useQuery) || { PersistQueryClientProvider: functionModules.find((m) => fnStr(m).includes("persistOptions")), QueryClient: functionModules.find((m) => fnStr(m).includes("defaultMutationOptions")), QueryClientProvider: functionModules.find((m) => fnStr(m).includes("use QueryClientProvider")), notifyManager: modules.find((m) => m?.setBatchNotifyFunction), useMutation: functionModules.find((m) => fnStr(m).includes("mutateAsync")), useQuery: functionModules.find((m) => fnStr(m).match(/^function [\w_$]+\(([\w_$]+),([\w_$]+)\)\{return\(0,[\w_$]+\.[\w_$]+\)\(\1,[\w_$]+\.[\w_$]+,\2\)\}$/) ), useQueryClient: functionModules.find((m) => fnStr(m).includes("client") && fnStr(m).includes("Provider") && fnStr(m).includes("mount")), useSuspenseQuery: functionModules.find( (m) => fnStr(m).includes("throwOnError") && fnStr(m).includes("suspense") && fnStr(m).includes("enabled") ), }, ReactFlipToolkit: { ...Spicetify.ReactFlipToolkit, Flipper: functionModules.find((m) => m?.prototype?.getSnapshotBeforeUpdate), Flipped: functionModules.find((m) => m.displayName === "Flipped"), }, _reservedPanelIds: modules.find((m) => m?.BuddyFeed), Mousetrap: cache.find((m) => m?.addKeycodes), Locale: modules.find((m) => m?._dictionary), }); if (!Spicetify.ContextMenuV2._context) Spicetify.ContextMenuV2._context = Spicetify.React.createContext({}); if (!Spicetify.ReactComponent.Navigation) Spicetify.ReactComponent.Navigation = exportedMemoFRefs.find((m) => fnStr(m.type.render).includes("navigationalRoot")); (function waitForChunks() { const listOfComponents = [ "Slider", "Dropdown", "Toggle", // "Cards.Artist", // "Cards.Audiobook", // "Cards.Profile", // "Cards.Show", // "Cards.Track", ]; if (listOfComponents.every((component) => component.split(".").reduce((o, k) => o?.[k], Spicetify.ReactComponent) !== undefined)) return; const cache = Object.keys(require.m).map((id) => require(id)); const modules = cache .filter((module) => typeof module === "object") .flatMap((module) => { try { return Object.values(module); } catch {} }); const functionModules = modules.flatMap((module) => typeof module === "function" ? [module] : typeof module === "object" && module ? Object.values(module).filter((v) => typeof v === "function" && !webpackFactories.has(v)) : [] ); const exportedMemos = modules.filter((m) => m?.$$typeof === Symbol.for("react.memo")); const cardTypesToFind = ["artist", "audiobook", "profile", "show", "track"]; // const cards = [ // ...functionModules // .flatMap((m) => { // return cardTypesToFind.map((type) => { // if (m.toString().includes(`featureIdentifier:"${type}"`)) { // cardTypesToFind.splice(cardTypesToFind.indexOf(type), 1); // return [type[0].toUpperCase() + type.slice(1), m]; // } // }); // }) // .filter(Boolean), // ...modules // .flatMap((m) => { // return cardTypesToFind.map((type) => { // try { // if (m?.type?.toString().includes(`featureIdentifier:"${type}"`)) { // cardTypesToFind.splice(cardTypesToFind.indexOf(type), 1); // return [type[0].toUpperCase() + type.slice(1), m]; // } // } catch {} // }); // }) // .filter(Boolean), // ]; Spicetify.ReactComponent.Slider = wrapProvider(functionModules.find((m) => fnStr(m).includes("progressBarRef"))); Spicetify.ReactComponent.Toggle = functionModules.find((m) => fnStr(m).includes("onSelected") && fnStr(m).includes('type:"checkbox"')); // Object.assign(Spicetify.ReactComponent.Cards, Object.fromEntries(cards)); // chunks const dropdownChunk = chunks.find(([, value]) => fnStr(value).includes("dropDown") && fnStr(value).includes("isSafari")); if (dropdownChunk) { Spicetify.ReactComponent.Dropdown = Object.values(require(dropdownChunk[0]))?.[0]?.render ?? Object.values(require(dropdownChunk[0])).find((m) => typeof m === "function"); } const toggleChunk = chunks.find(([, value]) => fnStr(value).includes("onSelected") && fnStr(value).includes('type:"checkbox"')); if (toggleChunk && !Spicetify.ReactComponent.Toggle) { Spicetify.ReactComponent.Toggle = Object.values(require(toggleChunk[0]))[0].render; } if (!listOfComponents.every((component) => component.split(".").reduce((o, k) => o?.[k], Spicetify.ReactComponent) !== undefined)) { setTimeout(waitForChunks, 100); return; } if (Spicetify.ReactComponent.ScrollableContainer) setTimeout(refreshNavLinks, 100); })(); (function waitForSnackbar() { if (!Object.keys(Spicetify.Snackbar).length) { setTimeout(waitForSnackbar, 100); return; } // Snackbar notifications // https://github.com/iamhosseindhv/notistack Spicetify.Snackbar = { ...Spicetify.Snackbar, SnackbarProvider: functionModules.find((m) => fnStr(m).includes("enqueueSnackbar called with invalid argument")), useSnackbar: functionModules.find((m) => fnStr(m).match(/^function\(\)\{return\(0,[\w$]+\.useContext\)\([\w$]+\)\}$/)), }; })(); const localeModule = modules.find((m) => m?.getTranslations); if (localeModule) { const createUrlLocale = functionModules.find((m) => fnStr(m).includes("has") && fnStr(m).includes("baseName") && fnStr(m).includes("language")); Spicetify.Locale = { get _relativeTimeFormat() { return localeModule._relativeTimeFormat; }, get _dateTimeFormats() { return localeModule._dateTimeFormats; }, get _locale() { return localeModule._localeForTranslation.baseName; }, get _urlLocale() { return localeModule._localeForURLPath; }, get _dictionary() { return localeModule._translations; }, formatDate: (date, options) => localeModule.formatDate(date, options), formatRelativeTime: (date, options) => localeModule.formatRelativeDate(date, options), formatNumber: (number, options) => localeModule.formatNumber(number, options), formatNumberCompact: (number, options) => localeModule.formatNumberCompact(number, options), get: (key, children) => localeModule.get(key, children), getDateTimeFormat: (options) => localeModule.getDateTimeFormat(options), getDictionary: () => localeModule.getTranslations(), getLocale: () => localeModule._localeForTranslation.baseName, getSmartlingLocale: () => localeModule.getLocaleForSmartling(), getUrlLocale: () => localeModule.getLocaleForURLPath(), getRelativeTimeFormat: () => localeModule.getRelativeTimeFormat(), getSeparator: () => localeModule.getSeparator(), setLocale: (locale) => { return localeModule.initialize({ localeForTranslation: locale, localeForFormatting: localeModule._localeForFormatting.baseName, translations: localeModule._translations, }); }, setUrlLocale: (locale) => { if (createUrlLocale) localeModule._localeForURLPath = createUrlLocale(locale); }, setDictionary: (dictionary) => { return localeModule.initialize({ localeForTranslation: localeModule._localeForTranslation.baseName, localeForFormatting: localeModule._localeForFormatting.baseName, translations: dictionary, }); }, toLocaleLowerCase: (text) => localeModule.toLocaleLowerCase(text), toLocaleUpperCase: (text) => localeModule.toLocaleUpperCase(text), }; } if (Spicetify.Locale) Spicetify.Locale._supportedLocales = cache.find((m) => typeof m?.ja === "string"); Object.defineProperty(Spicetify, "Queue", { get() { return Spicetify.Player.origin?._queue?._state ?? Spicetify.Player.origin?._queue?._queue; }, }); const confirmDialogChunk = chunks.find( ([, value]) => value.toString().includes("main-confirmDialog-container") || (value.toString().includes("confirmDialog") && value.toString().includes("shouldCloseOnEsc") && value.toString().includes("isOpen")) ); if (!Spicetify.ReactComponent?.ConfirmDialog && confirmDialogChunk) { Spicetify.ReactComponent.ConfirmDialog = Object.values(require(confirmDialogChunk[0])).find((m) => typeof m === "object"); } else { Spicetify.ReactComponent.ConfirmDialog = functionModules.find( (m) => fnStr(m).includes("isOpen") && fnStr(m).includes("shouldCloseOnEsc") && fnStr(m).includes("onClose") ); } const contextMenuChunk = chunks.find(([, value]) => value.toString().includes("handleContextMenu")); if (contextMenuChunk) { Spicetify.ReactComponent.ContextMenu = Object.values(require(contextMenuChunk[0])).find((m) => typeof m === "function"); } const playlistMenuChunk = chunks.find( ([, value]) => value.toString().includes('value:"playlist"') && value.toString().includes("canView") && value.toString().includes("permissions") ); if (playlistMenuChunk && !Spicetify.ReactComponent?.PlaylistMenu) { Spicetify.ReactComponent.PlaylistMenu = Object.values(require(playlistMenuChunk[0])).find( (m) => typeof m === "function" || typeof m === "object" ); } const infiniteQueryChunk = chunks.find( ([_, value]) => value.toString().includes("fetchPreviousPage") && value.toString().includes("getOptimisticResult") ); if (infiniteQueryChunk) { Spicetify.ReactQuery.useInfiniteQuery = Object.values(require(infiniteQueryChunk[0])).find((m) => typeof m === "function"); } if (Spicetify.Color) Spicetify.Color.CSSFormat = modules.find((m) => m?.RGBA); // Combine snackbar and notification (function bindShowNotification() { if (!Spicetify.Snackbar?.enqueueSnackbar && !Spicetify.showNotification) { setTimeout(bindShowNotification, 250); return; } if (Spicetify.Snackbar?.enqueueSnackbar) { Spicetify.showNotification = (message, isError, msTimeout) => { Spicetify.Snackbar.enqueueSnackbar(message, { variant: isError ? "error" : "default", autoHideDuration: msTimeout, }); }; return; } Spicetify.Snackbar.enqueueSnackbar = (message, { variant = "default", autoHideDuration } = {}) => { isError = variant === "error"; Spicetify.showNotification(message, isError, autoHideDuration); }; })(); // Image color extractor (async function bindColorExtractor() { if (!Spicetify.GraphQL.Request) { setTimeout(bindColorExtractor, 10); return; } let imageAnalysis = functionModules.find((m) => m.toString().match(/![\w$]+\.isFallback|\{extractColor/g)); const fallbackPreset = modules.find((m) => m?.colorDark); // Search chunk in Spotify 1.2.13 or much older because it is impossible to find any distinguishing features if (!imageAnalysis) { let chunk = chunks.find( ([, value]) => (value.toString().match(/[\w$]+\.isFallback/g) || value.toString().includes("colorRaw:")) && value.toString().match(/.extractColor/g) ); if (!chunk) { await new Promise((resolve) => setTimeout(resolve, 100)); chunk = chunks.find(([, value]) => value.toString().match(/[\w$]+\.isFallback/g) && value.toString().match(/.extractColor/g)); } imageAnalysis = Object.values(require(chunk[0])).find((m) => typeof m === "function"); } Spicetify.extractColorPreset = async (image) => { const analysis = await imageAnalysis(Spicetify.GraphQL.Request, image); for (const result of analysis) { if ("isFallback" in result === false) result.isFallback = fallbackPreset === result; } return analysis; }; })(); function wrapProvider(component) { if (!component) return null; return (props) => Spicetify.React.createElement( Spicetify.ReactComponent.RemoteConfigProvider, { configuration: Spicetify.Platform.RemoteConfiguration }, Spicetify.React.createElement(component, props) ); } (function waitForURI() { if (!Spicetify.URI) { setTimeout(waitForURI, 10); return; } // Ignore on versions older than 1.2.4 if (Spicetify.URI.Type) return; const URIChunk = cache .filter((module) => typeof module === "object") .find((m) => { // Avoid creating 2 arrays of the same values try { const values = Object.values(m); return values.some((m) => typeof m === "function") && values.some((m) => m?.PLAYLIST_V2); } catch { return false; } }); const URIModules = Object.values(URIChunk); // URI.Type Spicetify.URI.Type = URIModules.find((m) => m?.PLAYLIST_V2); // Parse functions Spicetify.URI.from = URIModules.find((m) => typeof m === "function" && m.toString().includes("allowedTypes")); Spicetify.URI.fromString = URIModules.find((m) => typeof m === "function" && m.toString().includes("Argument `uri`")); // createURI functions const createURIFunctions = URIModules.filter((m) => typeof m === "function" && m.toString().match(/\([\w$]+\./)); for (const type of Object.keys(Spicetify.URI.Type)) { const func = createURIFunctions.find((m) => m.toString().match(new RegExp(`\\([\\w$]+\\.${type}(?!_)`))); if (!func) continue; const camelCaseType = type .toLowerCase() .split("_") .map((word, index) => { if (index === 0) return word; return word[0].toUpperCase() + word.slice(1); }) .join(""); Spicetify.URI[`${camelCaseType}URI`] = func; } // isURI functions const isURIFUnctions = URIModules.filter((m) => typeof m === "function" && m.toString().match(/=[\w$]+\./)); for (const type of Object.keys(Spicetify.URI.Type)) { const func = isURIFUnctions.find((m) => m.toString().match(new RegExp(`===[\\w$]+\\.${type}(?!_)\\}`))); const camelCaseType = type .toLowerCase() .split("_") .map((word) => word[0].toUpperCase() + word.slice(1)) .join(""); // Fill in missing functions, only serves as placebo as they cannot be as accurate as the original functions Spicetify.URI[`is${camelCaseType}`] = func ?? ((uri) => { let uriObj; try { uriObj = Spicetify.URI.from?.(uri) ?? Spicetify.URI.fromString?.(uri); } catch { return false; } if (!uriObj) return false; return uriObj.type === Spicetify.URI.Type[type]; }); } Spicetify.URI.isPlaylistV1OrV2 = (uri) => Spicetify.URI.isPlaylist(uri) || Spicetify.URI.isPlaylistV2(uri); // Conversion functions Spicetify.URI.idToHex = URIModules.find((m) => typeof m === "function" && m.toString().includes("22===")); Spicetify.URI.hexToId = URIModules.find((m) => typeof m === "function" && m.toString().includes("32===")); // isSameIdentity Spicetify.URI.isSameIdentity = URIModules.find((m) => typeof m === "function" && m.toString().match(/[\w$]+\.id===[\w$]+\.id/)); })(); Spicetify.Events.webpackLoaded.fire(); refreshNavLinks?.(); })(); Spicetify.Events = (() => { class Event { callbacks = []; on(callback) { if (!this.callbacks) return void callback(); this.callbacks.push(callback); } fire() { for (const callback of this.callbacks) callback(); this.callbacks = undefined; } } return { webpackLoaded: new Event(), platformLoaded: new Event() }; })(); // Wait for Spicetify.Player.origin._state before adding following APIs (function waitOrigins() { if (!Spicetify?.Player?.origin?._state) { setTimeout(waitOrigins, 10); return; } const playerState = { cache: null, current: null, }; const interval = setInterval(() => { if (!Spicetify.Player.origin._state?.item) return; Spicetify.Player.data = Spicetify.Player.origin._state; playerState.cache = Spicetify.Player.data; clearInterval(interval); }, 10); Spicetify.Player.origin._events.addListener("update", ({ data: playerEventData }) => { playerState.current = playerEventData.item ? playerEventData : null; Spicetify.Player.data = playerState.current; if (playerState.cache?.item?.uri !== playerState.current?.item?.uri) { const event = new Event("songchange"); event.data = Spicetify.Player.data; Spicetify.Player.dispatchEvent(event); } if (playerState.cache?.isPaused !== playerState.current?.isPaused) { const event = new Event("onplaypause"); event.data = Spicetify.Player.data; Spicetify.Player.dispatchEvent(event); } playerState.cache = playerState.current; }); (function waitProductStateAPI() { if (!Spicetify.Platform?.UserAPI) { setTimeout(waitProductStateAPI, 100); return; } const productState = Spicetify.Platform.UserAPI._product_state || Spicetify.Platform.UserAPI._product_state_service; if (productState) return; if (!Spicetify.Platform?.ProductStateAPI) { setTimeout(waitProductStateAPI, 100); return; } const productStateApi = Spicetify.Platform.ProductStateAPI.productStateApi; Spicetify.Platform.UserAPI._product_state_service = productStateApi; })(); (async function setButtonsHeight() { while (!Spicetify.CosmosAsync) { await new Promise((res) => setTimeout(res, 100)); } const expFeatures = JSON.parse(localStorage.getItem("spicetify-exp-features") || "{}"); const isGlobalNavbar = expFeatures?.enableGlobalNavBar?.value; if (typeof isGlobalNavbar !== "undefined" && isGlobalNavbar === "control") { await Spicetify.CosmosAsync.post("sp://messages/v1/container/control", { type: "update_titlebar", height: Spicetify.Platform.PlatformData.os_name === "osx" ? "42" : "40", }); } })(); setInterval(() => { if (playerState.cache?.isPaused === false) { const event = new Event("onprogress"); event.data = Spicetify.Player.getProgress(); Spicetify.Player.dispatchEvent(event); } }, 100); Spicetify.addToQueue = (uri) => { return Spicetify.Player.origin._queue.addToQueue(uri); }; Spicetify.removeFromQueue = (uri) => { return Spicetify.Player.origin._queue.removeFromQueue(uri); }; })(); Spicetify.getAudioData = async (uri) => { const providedURI = uri || Spicetify.Player.data.item.uri; const uriObj = Spicetify.URI.from?.(providedURI) ?? Spicetify.URI.fromString?.(providedURI); if (!uriObj || (uriObj.Type || uriObj.type) !== Spicetify.URI.Type.TRACK) { throw "URI is invalid."; } return await Spicetify.CosmosAsync.get( `https://spclient.wg.spotify.com/audio-attributes/v1/audio-analysis/${uriObj.getBase62Id?.() ?? uriObj.id}?format=json` ); }; Spicetify.colorExtractor = async (uri) => { const body = await Spicetify.CosmosAsync.get(`https://spclient.wg.spotify.com/colorextractor/v1/extract-presets?uri=${uri}&format=json`); if (body.entries?.length) { const list = {}; for (const color of body.entries[0].color_swatches) { list[color.preset] = `#${color.color?.toString(16).padStart(6, "0")}`; } return list; } return null; }; Spicetify.LocalStorage = { clear: () => localStorage.clear(), get: (key) => localStorage.getItem(key), remove: (key) => localStorage.removeItem(key), set: (key, value) => localStorage.setItem(key, value), }; Spicetify._getStyledClassName = (args, component) => { const includedKeys = [ "role", "variant", "semanticColor", "iconColor", "color", "weight", "buttonSize", "iconSize", "position", "data-encore-id", "$size", "$iconColor", "$variant", "$semanticColor", "$buttonSize", "$position", "$iconSize", "$lineClamp", ]; const customKeys = ["blocksize"]; const customExactKeys = ["$padding", "$paddingBottom", "paddingBottom", "padding"]; const element = Array.from(args).find( (e) => e?.children || e?.dangerouslySetInnerHTML || typeof e?.className !== "undefined" || includedKeys.some((key) => typeof e?.[key] !== "undefined") || customExactKeys.some((key) => typeof e?.[key] !== "undefined") || customKeys.some((key) => Object.keys(e).some((k) => k.toLowerCase().includes(key))) ); if (!element) return; let className = /(?:\w+__)?(\w+)-[\w-]+/.exec(component.componentId)?.[1]; for (const key of includedKeys) { if ((typeof element[key] === "string" && element[key].length) || typeof element[key] === "number") { className += `-${element[key]}`; } } const excludedKeys = ["children", "className", "style", "dir", "key", "ref", "as", "$autoMirror", "autoMirror", "$hasFocus", ""]; const excludedPrefix = ["aria-"]; const childrenProps = ["iconLeading", "iconTrailing", "iconOnly", "$iconOnly", "$iconLeading", "$iconTrailing"]; for (const key of childrenProps) { const sanitizedKey = key.startsWith("$") ? key.slice(1) : key; if (element[key]) className += `-${sanitizedKey}`; } const booleanKeys = Object.keys(element).filter((key) => typeof element[key] === "boolean" && element[key]); for (const key of booleanKeys) { if (excludedKeys.includes(key)) continue; if (excludedPrefix.some((prefix) => key.startsWith(prefix))) continue; const sanitizedKey = key.startsWith("$") ? key.slice(1) : key; className += `-${sanitizedKey}`; } const customEntries = Object.entries(element).filter( ([key, value]) => (customKeys.some((k) => key.toLowerCase().includes(k)) || customExactKeys.some((k) => key === k)) && typeof value === "string" && value.length ); for (const [key, value] of customEntries) { const sanitizedKey = key.startsWith("$") ? key.slice(1) : key; className += `-${sanitizedKey}_${value.replace(/[^a-z0-9]/gi, "_")}`; } return className; }; (function waitMouseTrap() { if (!Spicetify.Mousetrap) { setTimeout(waitMouseTrap, 10); return; } const KEYS = { BACKSPACE: "backspace", TAB: "tab", ENTER: "enter", SHIFT: "shift", CTRL: "ctrl", ALT: "alt", CAPS: "capslock", ESCAPE: "esc", SPACE: "space", PAGE_UP: "pageup", PAGE_DOWN: "pagedown", END: "end", HOME: "home", ARROW_LEFT: "left", ARROW_UP: "up", ARROW_RIGHT: "right", ARROW_DOWN: "down", INSERT: "ins", DELETE: "del", A: "a", B: "b", C: "c", D: "d", E: "e", F: "f", G: "g", H: "h", I: "i", J: "j", K: "k", L: "l", M: "m", N: "n", O: "o", P: "p", Q: "q", R: "r", S: "s", T: "t", U: "u", V: "v", W: "w", X: "x", Y: "y", Z: "z", WINDOW_LEFT: "meta", WINDOW_RIGHT: "meta", SELECT: "meta", NUMPAD_0: "0", NUMPAD_1: "1", NUMPAD_2: "2", NUMPAD_3: "3", NUMPAD_4: "4", NUMPAD_5: "5", NUMPAD_6: "6", NUMPAD_7: "7", NUMPAD_8: "8", NUMPAD_9: "9", MULTIPLY: "*", ADD: "+", SUBTRACT: "-", DECIMAL_POINT: ".", DIVIDE: "/", F1: "f1", F2: "f2", F3: "f3", F4: "f4", F5: "f5", F6: "f6", F7: "f7", F8: "f8", F9: "f9", F10: "f10", F11: "f11", F12: "f12", ";": ";", "=": "=", ",": ",", "-": "-", ".": ".", "/": "/", "`": "`", "[": "[", "\\": "\\", "]": "]", // biome-ignore lint/suspicious/noDuplicateObjectKeys: Not an issue '"': '"', "~": "`", "!": "1", "@": "2", "#": "3", $: "4", "%": "5", "^": "6", "&": "7", "*": "8", "(": "9", ")": "0", _: "-", "+": "=", ":": ";", '"': "'", "<": ",", ">": ".", "?": "/", "|": "\\", }; function formatKeys(keys) { let keystroke = ""; if (typeof keys === "object") { if (!keys.key || !Object.values(KEYS).includes(keys.key)) { throw `Spicetify.Keyboard.registerShortcut: Invalid key ${keys.key}`; } if (keys.ctrl) keystroke += "mod+"; if (keys.meta) keystroke += "meta+"; if (keys.alt) keystroke += "alt+"; if (keys.shift) keystroke += "shift+"; keystroke += keys.key; } else if (typeof keys === "string" && Object.values(KEYS).includes(keys)) { keystroke = keys; } else { throw `Spicetify.Keyboard.registerShortcut: Invalid keys ${keys}`; } return keystroke; } Spicetify.Keyboard = { KEYS, registerShortcut: (keys, callback) => { Spicetify.Mousetrap.bind(formatKeys(keys), callback); }, _deregisterShortcut: (keys) => { Spicetify.Mousetrap.unbind(formatKeys(keys)); }, changeShortcut: (keys, newKeys) => { if (!keys || !newKeys) throw "Spicetify.Keyboard.changeShortcut: Invalid keys"; const callback = Object.keys(Spicetify.Mousetrap.trigger()._directMap).find((key) => key.startsWith(formatKeys(keys))); if (!callback) throw "Spicetify.Keyboard.changeShortcut: Shortcut not found"; Spicetify.Keyboard.registerShortcut(newKeys, Spicetify.Mousetrap.trigger()._directMap[callback]); Spicetify.Keyboard._deregisterShortcut(keys); }, }; Spicetify.Keyboard.registerIsolatedShortcut = Spicetify.Keyboard.registerShortcut; Spicetify.Keyboard.registerImportantShortcut = Spicetify.Keyboard.registerShortcut; Spicetify.Keyboard.deregisterImportantShortcut = Spicetify.Keyboard._deregisterShortcut; })(); Spicetify.SVGIcons = { collaborative: '', album: '', artist: '', block: '', brightness: '', car: '', "chart-down": '', "chart-up": '', check: '', "check-alt-fill": '', "chevron-left": '', "chevron-right": '', "chromecast-disconnected": '', clock: '', computer: '', copy: '', download: '', downloaded: '', edit: '', enhance: '', "exclamation-circle": '', "external-link": '', facebook: '', follow: '', fullscreen: '', gamepad: '', "grid-view": '', heart: '', "heart-active": '', instagram: '', laptop: '', library: '', "list-view": '', location: '', locked: '', "locked-active": '', lyrics: '', menu: '', minimize: '', minus: '', more: '', "new-spotify-connect": '', offline: '', pause: '', phone: '', play: '', playlist: '', "playlist-folder": '', plus2px: '', "plus-alt": '', podcasts: '', projector: '', queue: '', repeat: '', "repeat-once": '', search: '', "search-active": '', shuffle: '', "skip-back": '', "skip-back15": '', "skip-forward": '', "skip-forward15": '', soundbetter: '', speaker: '', spotify: '', subtitles: '', tablet: '', ticket: '', twitter: '', visualizer: '', voice: '', volume: '', "volume-off": '', "volume-one-wave": '', "volume-two-wave": '', watch: '', x: '', }; (async function waitUserAPI() { if (!Spicetify.Platform?.UserAPI) { setTimeout(waitUserAPI, 1000); return; } let subRequest; // product_state was renamed to product_state_service in Spotify 1.2.21 const productState = Spicetify.Platform.UserAPI?._product_state || Spicetify.Platform.UserAPI?._product_state_service || Spicetify.Platform?.ProductStateAPI.productStateApi; Spicetify.AppTitle = { set: async (name) => { if (subRequest) subRequest.cancel(); await productState.putOverridesValues({ pairs: { name } }); subRequest = productState.subValues({ keys: ["name"] }, ({ pairs }) => { if (pairs.name !== name) { productState.putOverridesValues({ pairs: { name } }); // Restore name } }); return subRequest; }, get: async () => { const value = await productState.getValues(); return value.pairs.name; }, reset: async () => { if (subRequest) subRequest.cancel(); await productState.delOverridesValues({ keys: ["name"] }); }, sub: (callback) => { return productState.subValues({ keys: ["name"] }, ({ pairs }) => { callback(pairs.name); }); }, }; })(); function parseIcon(icon, size = 16) { if (icon && Spicetify.SVGIcons[icon]) { return `${Spicetify.SVGIcons[icon]}`; } return icon || ""; } function createIconComponent(icon, size = 16) { return Spicetify.React.createElement( Spicetify.ReactComponent.IconComponent, { iconSize: size, dangerouslySetInnerHTML: { __html: parseIcon(icon), }, }, null ); } Spicetify.ContextMenuV2 = (() => { const registeredItems = new Map(); function parseProps(props) { if (!props) return; const uri = props.uri ?? props.item?.uri ?? props.reference?.uri; const uris = props.uris ?? (uri ? [uri] : undefined); if (!uris) return; const uid = props.uid ?? props.item?.uid; const uids = props.uids ?? (uid ? [uid] : undefined); const contextUri = props.contextUri ?? props.context?.uri; return [uris, uids, contextUri]; } // these classes bridge the gap between react and js, insuring reactivity class Item { constructor({ children, disabled = false, leadingIcon, trailingIcon, divider, onClick, shouldAdd = () => true }) { // maybe use a props object and a setProps this.shouldAdd = shouldAdd; this._children = children; this._disabled = disabled; this._leadingIcon = leadingIcon; this._trailingIcon = trailingIcon; this._divider = divider; this._element = Spicetify.ReactJSX.jsx(() => { const [_children, setChildren] = Spicetify.React.useState(this._children); const [_disabled, setDisabled] = Spicetify.React.useState(this._disabled); const [_leadingIcon, setLeadingIcon] = Spicetify.React.useState(this._leadingIcon); const [_trailingIcon, setTrailingIcon] = Spicetify.React.useState(this._trailingIcon); const [_divider, setDivider] = Spicetify.React.useState(this._divider); Spicetify.React.useEffect(() => { this._setChildren = setChildren; this._setDisabled = setDisabled; this._setIcon = setLeadingIcon; this._setTrailingIcon = setTrailingIcon; this._setDivider = setDivider; return () => { this._setChildren = undefined; this._setDisabled = undefined; this._setIcon = undefined; this._setTrailingIcon = undefined; this._setDivider = undefined; }; }); const context = Spicetify.React.useContext(Spicetify.ContextMenuV2._context) ?? {}; return Spicetify.React.createElement(Spicetify.ReactComponent.MenuItem, { disabled: _disabled, divider: _divider, onClick: (e) => { onClick(context, this, e); }, leadingIcon: _leadingIcon && createIconComponent(_leadingIcon), trailingIcon: _trailingIcon && createIconComponent(_trailingIcon), children: _children, }); }, {}); } set children(children) { this._children = children; this._setChildren?.(this._children); } get children() { return this._children; } set disabled(bool) { this._disabled = bool; this._setDisabled?.(this._disabled); } get disabled() { return this._disabled; } set leadingIcon(name) { this._leadingIcon = name; this._setIcon?.(this._leadingIcon); } get leadingIcon() { return this._leadingIcon; } set trailingIcon(name) { this._trailingIcon = name; this._setTrailingIcon?.(this._trailingIcon); } get trailingIcon() { return this._trailingIcon; } set divider(divider) { this._divider = divider; this._setDivider?.(this._divider); } get divider() { return this._divider; } register() { Spicetify.ContextMenuV2.registerItem(this._element, this.shouldAdd); } deregister() { Spicetify.ContextMenuV2.unregisterItem(this._element); } } class ItemSubMenu { static itemsToComponents(items, props, trigger, target, parentDepth = 1) { return items .filter((item) => (item.shouldAdd || (() => true))?.(props, trigger, target)) .map((item) => { if (item instanceof ItemSubMenu) item.depth = parentDepth + 1; return item._element; }); } constructor({ text, disabled = false, leadingIcon, divider, items, depth = 1, shouldAdd = () => true }) { this.shouldAdd = shouldAdd; this._text = text; this._disabled = disabled; this._leadingIcon = leadingIcon; this._divider = divider; this._items = items; this._depth = depth; this._element = Spicetify.ReactJSX.jsx(() => { const [_text, setText] = Spicetify.React.useState(this._text); const [_disabled, setDisabled] = Spicetify.React.useState(this._disabled); const [_leadingIcon, setLeadingIcon] = Spicetify.React.useState(this._leadingIcon); const [_divider, setDivider] = Spicetify.React.useState(this._divider); const [_items, setItems] = Spicetify.React.useState(this._items); const [_depth, setDepth] = Spicetify.React.useState(this._depth); Spicetify.React.useEffect(() => { this._setText = setText; this._setDisabled = setDisabled; this._setLeadingIcon = setLeadingIcon; this._setDivider = setDivider; this._setItems = setItems; this._setDepth = setDepth; return () => { this._setText = undefined; this._setDisabled = undefined; this._setLeadingIcon = undefined; this._setDivider = undefined; this._setItems = undefined; this._setDepth = undefined; }; }); const context = Spicetify.React.useContext(Spicetify.ContextMenuV2._context) ?? {}; const { props, trigger, target } = context; return Spicetify.React.createElement(Spicetify.ReactComponent.MenuSubMenuItem, { displayText: _text, divider: _divider, depth: _depth, placement: "right-start", onOpenChange: () => undefined, onClick: () => undefined, disabled: _disabled, leadingIcon: _leadingIcon && createIconComponent(_leadingIcon), children: ItemSubMenu.itemsToComponents(_items, props, trigger, target, _depth), }); }, {}); } set text(text) { this._text = text; this._setText?.(this._text); } get text() { return this._text; } set disabled(bool) { this._disabled = bool; this._setDisabled?.(this._disabled); } get disabled() { return this._disabled; } set leadingIcon(name) { this._leadingIcon = name; this._setIcon?.(this._leadingIcon); } get leadingIcon() { return this._leadingIcon; } set divider(divider) { this._divider = divider; this._setDivider?.(this._divider); } get divider() { return this._divider; } set depth(value) { this._depth = value; this._setDepth?.(this._depth); } get depth() { return this._depth; } addItem(item) { this._items.add(item); this._setItems?.(this._items); } removeItem(item) { this._items.delete(item); this._setItems?.(this._items); } register() { registerItem(this._element, this.shouldAdd); } deregister() { unregisterItem(this._element); } } function registerItem(item, shouldAdd = () => true) { registeredItems.set(item, shouldAdd); } function unregisterItem(item) { registeredItems.delete(item); } const renderItems = () => { const { props, trigger, target } = Spicetify.React.useContext(Spicetify.ContextMenuV2._context) ?? {}; return Array.from(registeredItems.entries()) .map(([item, shouldAdd]) => shouldAdd?.(props, trigger, target) && item) .filter(Boolean); }; return { parseProps, Item, ItemSubMenu, registerItem, unregisterItem, renderItems }; })(); Spicetify.Menu = (() => { const shouldAdd = (_, trigger, target) => trigger === "click" && (target.classList.contains("main-userWidget-boxCondensed") || target.classList.contains("main-userWidget-box")); class Item extends Spicetify.ContextMenuV2.Item { constructor(children, isEnabled, onClick, leadingIcon) { super({ children, leadingIcon, onClick: (_, self) => onClick(self), shouldAdd }); this.isEnabled = isEnabled; } setState(state) { this.isEnabled = state; } set isEnabled(bool) { this._isEnabled = bool; this.trailingIcon = this.isEnabled ? "check" : ""; } get isEnabled() { return this._isEnabled; } } class SubMenu extends Spicetify.ContextMenuV2.ItemSubMenu { constructor(text, items, leadingIcon) { super({ text, leadingIcon, items, shouldAdd }); } set name(text) { this.text = text; } get name() { return this.text; } set icon(icon) { this.leadingIcon = icon; } get icon() { return this.leadingIcon; } } return { Item, SubMenu }; })(); Spicetify.ContextMenu = (() => { const iconList = Object.keys(Spicetify.SVGIcons); class Item extends Spicetify.ContextMenuV2.Item { static iconList = iconList; constructor(name, onClick, shouldAdd = () => true, icon = undefined, trailingIcon = undefined, disabled = false) { super({ children: name, disabled, leadingIcon: icon, trailingIcon, onClick: (context) => { const [uris, uids, contextUri] = Spicetify.ContextMenuV2.parseProps(context.props); onClick(uris, uids, contextUri); }, shouldAdd: (props) => { const parsedProps = Spicetify.ContextMenuV2.parseProps(props); return parsedProps && shouldAdd(...parsedProps); }, }); } set name(name) { this.children = name; } get name() { return this.children; } set icon(name) { this.leadingIcon = name; } get icon() { return this.leadingIcon; } } class SubMenu extends Spicetify.ContextMenuV2.ItemSubMenu { static iconList = iconList; constructor(name, items, shouldAdd, disabled = false, icon = undefined) { super({ text: name, disabled, leadingIcon: icon, items, shouldAdd: (props) => { const parsedProps = Spicetify.ContextMenuV2.parseProps(props); return parsedProps && shouldAdd(...parsedProps); }, }); } set name(name) { this.text = name; } get name() { return this.text; } } return { Item, SubMenu }; })(); let navLinkFactoryCtx = null; let refreshNavLinks = null; Spicetify._renderNavLinks = (list, isTouchScreenUi) => { const [refreshCount, refresh] = Spicetify.React.useReducer((x) => x + 1, 0); refreshNavLinks = refresh; if ( !Spicetify.ReactComponent.ButtonTertiary || !Spicetify.ReactComponent.Navigation || !Spicetify.ReactComponent.TooltipWrapper || !Spicetify.ReactComponent.ScrollableContainer || !Spicetify.Platform.History || !Spicetify.Platform.LocalStorageAPI ) return; const navLinkFactory = isTouchScreenUi ? NavLinkGlobal : NavLinkSidebar; if (!navLinkFactoryCtx) navLinkFactoryCtx = Spicetify.React.createContext(null); const registered = []; for (const app of list) { let manifest; try { const request = new XMLHttpRequest(); request.open("GET", `spicetify-routes-${app}.json`, false); request.send(null); manifest = JSON.parse(request.responseText); } catch { manifest = {}; } let appProper = manifest.name; if (typeof appProper === "object") { appProper = appProper[Spicetify.Locale?.getLocale()] || appProper.en; } if (!appProper) { appProper = app[0].toUpperCase() + app.slice(1); } const icon = manifest.icon || ""; const activeIcon = manifest["active-icon"] || icon; const appRoutePath = `/${app}`; registered.push({ appProper, appRoutePath, icon, activeIcon }); } (function addStyling() { if (document.querySelector("style.spicetify-navlinks")) return; const style = document.createElement("style"); style.className = "spicetify-navlinks"; style.innerHTML = ` :root { --max-custom-navlink-count: 4; } .custom-navlinks-scrollable_container { max-width: calc(48px * var(--max-custom-navlink-count) + 8px * (var(--max-custom-navlink-count) - 1)); -webkit-app-region: no-drag; } .custom-navlinks-scrollable_container div[role="presentation"] > *:not(:last-child) { margin-inline-end: 8px; } .custom-navlinks-scrollable_container div[role="presentation"] { display: flex; flex-direction: row; } .custom-navlink { -webkit-app-region: unset; } `; document.head.appendChild(style); })(); const wrapScrollableContainer = (element) => Spicetify.React.createElement( "div", { className: "custom-navlinks-scrollable_container" }, Spicetify.React.createElement(Spicetify.ReactComponent.ScrollableContainer, null, element) ); const NavLinks = () => Spicetify.React.createElement( navLinkFactoryCtx.Provider, { value: navLinkFactory }, registered.map((NavLinkElement) => Spicetify.React.createElement(NavLink, NavLinkElement, null)) ); return isTouchScreenUi ? wrapScrollableContainer(NavLinks()) : NavLinks(); }; const NavLink = ({ appProper, appRoutePath, icon, activeIcon }) => { const isActive = Spicetify.Platform.History.location.pathname?.startsWith(appRoutePath); const createIcon = () => createIconComponent(isActive ? activeIcon : icon, 24); const NavLinkFactory = Spicetify.React.useContext(navLinkFactoryCtx); return NavLinkFactory && Spicetify.React.createElement(NavLinkFactory, { appProper, appRoutePath, createIcon, isActive }, null); }; const NavLinkSidebar = ({ appProper, appRoutePath, createIcon, isActive }) => { const isSidebarCollapsed = Spicetify.Platform.LocalStorageAPI.getItem("ylx-sidebar-state") === 1; return Spicetify.React.createElement( "li", { className: "main-yourLibraryX-navItem InvalidDropTarget" }, Spicetify.React.createElement( Spicetify.ReactComponent.TooltipWrapper, { label: isSidebarCollapsed ? appProper : null, disabled: !isSidebarCollapsed, placement: "right" }, Spicetify.React.createElement( Spicetify.ReactComponent.Navigation, { to: appRoutePath, referrer: "other", className: Spicetify.classnames("link-subtle", "main-yourLibraryX-navLink", { "main-yourLibraryX-navLinkActive": isActive, }), onClick: () => undefined, "aria-label": appProper, }, createIcon(), !isSidebarCollapsed && Spicetify.React.createElement(Spicetify.ReactComponent.TextComponent, { variant: "balladBold" }, appProper) ) ) ); }; const NavLinkGlobal = ({ appProper, appRoutePath, createIcon, isActive }) => { return Spicetify.React.createElement( Spicetify.ReactComponent.TooltipWrapper, { label: appProper }, Spicetify.React.createElement(Spicetify.ReactComponent.ButtonTertiary, { iconOnly: createIcon, className: Spicetify.classnames("link-subtle", "main-globalNav-navLink", "main-globalNav-link-icon", "custom-navlink", { "main-globalNav-navLinkActive": isActive, }), "aria-label": appProper, onClick: () => Spicetify.Platform.History.push(appRoutePath), }) ); }; class _HTMLGenericModal extends HTMLElement { hide() { Spicetify.ReactDOM.unmountComponentAtNode(this.querySelector("main")); this.remove(); } display({ title, content, isLarge = false }) { this.innerHTML = `
    `; this.querySelector("button").onclick = this.hide.bind(this); const main = this.querySelector("main"); const hidePopup = this.hide.bind(this); // Listen for click events on Overlay this.querySelector(".GenericModal__overlay").addEventListener("click", (event) => { if (!this.querySelector(".GenericModal").contains(event.target)) hidePopup(); }); if (Spicetify.React.isValidElement(content)) { Spicetify.ReactDOM.render(content, main); } else if (typeof content === "string") { main.innerHTML = content; } else { main.append(content); } document.body.append(this); } } customElements.define("generic-modal", _HTMLGenericModal); Spicetify.PopupModal = new _HTMLGenericModal(); Object.defineProperty(Spicetify, "TippyProps", { value: { delay: [200, 0], animation: true, render(instance) { const popper = document.createElement("div"); const box = document.createElement("div"); popper.id = "context-menu"; popper.appendChild(box); box.className = "main-contextMenu-tippy"; box[instance.props.allowHTML ? "innerHTML" : "textContent"] = instance.props.content; function onUpdate(prevProps, nextProps) { if (prevProps.content !== nextProps.content) { if (nextProps.allowHTML) box.innerHTML = nextProps.content; else box.textContent = nextProps.content; } } return { popper, onUpdate }; }, onShow(instance) { instance.popper.firstChild.classList.add("main-contextMenu-tippyEnter"); }, onMount(instance) { requestAnimationFrame(() => { instance.popper.firstChild.classList.remove("main-contextMenu-tippyEnter"); instance.popper.firstChild.classList.add("main-contextMenu-tippyEnterActive"); }); }, onHide(instance) { requestAnimationFrame(() => { instance.popper.firstChild.classList.remove("main-contextMenu-tippyEnterActive"); instance.unmount(); }); }, }, writable: false, }); Spicetify.Topbar = (() => { let leftGeneratedClassName; let rightGeneratedClassName; let leftContainer; let rightContainer; const leftButtonsStash = new Set(); const rightButtonsStash = new Set(); class Button { constructor(label, icon, onClick, disabled = false, isRight = false) { this.element = document.createElement("div"); this.button = document.createElement("button"); this.icon = icon; this.onClick = onClick; this.disabled = disabled; this.tippy = Spicetify.Tippy?.(this.element, { content: label, ...Spicetify.TippyProps, }); this.label = label; this.element.appendChild(this.button); if (isRight) { this.button.className = rightGeneratedClassName; rightButtonsStash.add(this.element); rightContainer?.prepend(this.element); } else { this.button.className = leftGeneratedClassName; leftButtonsStash.add(this.element); leftContainer?.append(this.element); } } get label() { return this._label; } set label(text) { this._label = text; this.button.setAttribute("aria-label", text); if (!this.tippy) this.button.setAttribute("title", text); else this.tippy.setContent(text); } get icon() { return this._icon; } set icon(input) { let newInput = input; if (newInput && Spicetify.SVGIcons[newInput]) { newInput = `${Spicetify.SVGIcons[newInput]}`; } this._icon = newInput; this.button.innerHTML = newInput; } get onClick() { return this._onClick; } set onClick(func) { this._onClick = func; this.button.onclick = () => this._onClick(this); } get disabled() { return this._disabled; } set disabled(bool) { this._disabled = bool; this.button.disabled = bool; this.button.classList.toggle("disabled", bool); } } function waitForTopbarMounted() { const globalHistoryButtons = document.querySelector(".main-globalNav-historyButtons"); leftGeneratedClassName = document.querySelector( ".main-topBar-historyButtons .main-topBar-button, .main-globalNav-historyButtons .main-globalNav-icon, .main-globalNav-historyButtons [data-encore-id='buttonTertiary']" )?.className; rightGeneratedClassName = document.querySelector( ".main-topBar-container .main-topBar-buddyFeed, .main-actionButtons .main-topBar-buddyFeed, .main-actionButtons .main-globalNav-buddyFeed" )?.className; leftContainer = document.querySelector(".main-topBar-historyButtons") ?? globalHistoryButtons; rightContainer = document.querySelector(".main-actionButtons"); if (!leftContainer || !rightContainer || !leftGeneratedClassName || !rightGeneratedClassName) { setTimeout(waitForTopbarMounted, 100); return; } if (globalHistoryButtons) globalHistoryButtons.style = "gap: 4px; padding-inline: 4px 4px"; for (const button of leftButtonsStash) { if (button.parentNode) button.parentNode.removeChild(button); const buttonElement = button.querySelector("button"); buttonElement.className = leftGeneratedClassName; } leftContainer.append(...leftButtonsStash); for (const button of rightButtonsStash) { if (button.parentNode) button.parentNode.removeChild(button); const buttonElement = button.querySelector("button"); buttonElement.className = rightGeneratedClassName; } rightContainer.prepend(...rightButtonsStash); } waitForTopbarMounted(); (function waitForPlatform() { if (!Spicetify.Platform?.History) { setTimeout(waitForPlatform, 100); return; } Spicetify.Platform.History.listen(() => waitForTopbarMounted()); })(); return { Button }; })(); Spicetify.Playbar = (() => { let rightContainer; let sibling; const buttonsStash = new Set(); class Button { constructor(label, icon, onClick = () => {}, disabled = false, active = false, registerOnCreate = true) { this.element = document.createElement("button"); this.element.classList.add("main-genericButton-button"); this.iconElement = document.createElement("span"); this.iconElement.classList.add("Wrapper-sm-only", "Wrapper-small-only"); this.element.appendChild(this.iconElement); this.icon = icon; this.onClick = onClick; this.disabled = disabled; this.active = active; addClassname(this.element); this.tippy = Spicetify.Tippy?.(this.element, { content: label, ...Spicetify.TippyProps, }); this.label = label; registerOnCreate && this.register(); } get label() { return this._label; } set label(text) { this._label = text; if (!this.tippy) this.element.setAttribute("title", text); else this.tippy.setContent(text); } get icon() { return this._icon; } set icon(input) { let newInput = input; if (newInput && Spicetify.SVGIcons[newInput]) { newInput = `${Spicetify.SVGIcons[newInput]}`; } this._icon = newInput; this.iconElement.innerHTML = newInput; } get onClick() { return this._onClick; } set onClick(func) { this._onClick = func; this.element.onclick = () => this._onClick(this); } get disabled() { return this._disabled; } set disabled(bool) { this._disabled = bool; this.element.disabled = bool; this.element.classList.toggle("disabled", bool); } set active(bool) { this._active = bool; this.element.classList.toggle("main-genericButton-buttonActive", bool); this.element.classList.toggle("main-genericButton-buttonActiveDot", bool); } get active() { return this._active; } register() { buttonsStash.add(this.element); rightContainer?.prepend(this.element); } deregister() { buttonsStash.delete(this.element); this.element.remove(); } } (function waitForPlaybarMounted() { rightContainer = document.querySelector(".main-nowPlayingBar-right > div"); if (!rightContainer) { setTimeout(waitForPlaybarMounted, 300); return; } for (const button of buttonsStash) { addClassname(button); } rightContainer.prepend(...buttonsStash); })(); function addClassname(element) { sibling = document.querySelector(".main-nowPlayingBar-right .main-genericButton-button"); if (!sibling) { setTimeout(addClassname, 300, element); return; } for (const className of Array.from(sibling.classList)) { if (!className.startsWith("main-genericButton")) element.classList.add(className); } } const widgetStash = new Set(); let nowPlayingWidget; class Widget { constructor(label, icon, onClick = () => {}, disabled = false, active = false, registerOnCreate = true) { this.element = document.createElement("button"); this.element.className = "main-addButton-button control-button control-button-heart"; this.icon = icon; this.onClick = onClick; this.disabled = disabled; this.active = active; this.tippy = Spicetify.Tippy?.(this.element, { content: label, ...Spicetify.TippyProps, }); this.label = label; registerOnCreate && this.register(); } get label() { return this._label; } set label(text) { this._label = text; if (!this.tippy) this.element.setAttribute("title", text); else this.tippy.setContent(text); } get icon() { return this._icon; } set icon(input) { let newInput = input; if (newInput && Spicetify.SVGIcons[newInput]) { newInput = `${Spicetify.SVGIcons[newInput]}`; } this._icon = newInput; this.element.innerHTML = newInput; } get onClick() { return this._onClick; } set onClick(func) { this._onClick = func; this.element.onclick = () => this._onClick(this); } get disabled() { return this._disabled; } set disabled(bool) { this._disabled = bool; this.element.disabled = bool; this.element.classList.toggle("main-addButton-disabled", bool); this.element.ariaDisabled = bool; } set active(bool) { this._active = bool; this.element.classList.toggle("main-addButton-active", bool); this.element.ariaChecked = bool; } get active() { return this._active; } register() { widgetStash.add(this.element); nowPlayingWidget?.append(this.element); } deregister() { widgetStash.delete(this.element); this.element.remove(); } } function waitForWidgetMounted() { nowPlayingWidget = document.querySelector(".main-nowPlayingWidget-nowPlaying"); if (!nowPlayingWidget) { setTimeout(waitForWidgetMounted, 300); return; } nowPlayingWidget.append(...widgetStash); } (function attachObserver() { const leftPlayer = document.querySelector(".main-nowPlayingBar-left"); if (!leftPlayer) { setTimeout(attachObserver, 300); return; } waitForWidgetMounted(); const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.removedNodes.length > 0) { nowPlayingWidget = null; waitForWidgetMounted(); } } }); observer.observe(leftPlayer, { childList: true }); })(); return { Button, Widget }; })(); (async function checkForUpdate() { if (!Spicetify.Config) { setTimeout(checkForUpdate, 300); return; } const { check_spicetify_update, version } = Spicetify.Config; // Skip checking if upgrade check is disabled, or version is Dev/version is not set if (!check_spicetify_update || !version || version === "Dev") return; // Fetch latest version from GitHub try { let changelog; const res = await fetch("https://api.github.com/repos/spicetify/cli/releases/latest"); const { tag_name, html_url, body } = await res.json(); const semver = tag_name.slice(1); const changelogRawDataOld = body.match(/## What's Changed([\s\S]*?)\r\n\r/)?.[1]; if (changelogRawDataOld) { changelog = [...changelogRawDataOld.matchAll(/\r\n\*\s(.+?)\sin\shttps/g)] .map((match) => { const featureData = match[1].split("@"); const feature = featureData[0]; const committerID = featureData[1]; return `
  • ${feature}${committerID}
  • `; }) .join("\n"); } else { const sections = body.split("\n## "); const filteredSections = sections.filter((section) => !section.startsWith("Compatibility")); const filteredText = filteredSections.join("\n## "); changelog = [...filteredText.matchAll(/- (?:\*\*(.+?)\*\*:? )?(.+?) \(\[(.+?)\]\((.+?)\)\)/g)] .map((match) => { const feature = match[1]; const description = match[2]; const prNumber = match[3]; const prLink = match[4]; let text = "
  • "; if (feature) text += `${feature}${!feature.endsWith(":") ? ": " : " "}`; text += `${description} (${prNumber})
  • `; return text; }) .join("\n"); } if (semver !== version) { const content = document.createElement("div"); content.id = "spicetify-update"; content.innerHTML = `

    Update Spicetify to receive new features and bug fixes.

    Current version: ${version}

    Latest version: ${semver}


    What's Changed

    See changelog
      ${changelog}

    Guide

    Run these commands in the terminal:

    1. Update Spicetify CLI
    2. spicetify update

      Spicetify will automatically apply changes to Spotify after upgrading to the latest version.

      If you installed Spicetify via a package manager, update using said package manager.

    `; (function waitForTippy() { if (!Spicetify.Tippy) { setTimeout(waitForTippy, 300); return; } const tippy = Spicetify.Tippy(content.querySelectorAll("pre"), { content: "Click to copy", hideOnClick: false, ...Spicetify.TippyProps, }); for (const instance of tippy) { instance.reference.addEventListener("click", () => { Spicetify.Platform.ClipboardAPI.copy(instance.reference.textContent); instance.setContent("Copied!"); setTimeout(() => instance.setContent("Click to copy"), 1000); }); } })(); const updateModal = { title: "Update Spicetify", content, isLarge: true, }; new Spicetify.Topbar.Button( "Update Spicetify", ``, () => Spicetify.PopupModal.display(updateModal) ); } } catch (err) { console.error(err); } })(); ================================================ FILE: manifest.json ================================================ [ { "name": "Auto Skip Videos", "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.", "preview": null, "main": "Extensions/autoSkipVideo.js" }, { "name": "Bookmark", "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.", "preview": "https://i.imgur.com/isgU4TS.png", "main": "Extensions/bookmark.js" }, { "name": "Christian Spotify", "description": "Auto skip explicit tracks. Toggle option is in Profile menu (top right button).", "preview": "https://i.imgur.com/5reGrBb.png", "main": "Extensions/autoSkipExplicit.js" }, { "name": "Keyboard Shortcut", "description": "Register some useful keybinds to support keyboard-driven navigation in Spotify client. Less time touching the mouse.", "preview": "https://i.imgur.com/evkGv9q.png", "main": "Extensions/keyboardShortcut.js" }, { "name": "Loopy Loop", "description": "Provide ability to mark start and end points on progress bar and automatically loop over that track portion.", "preview": "https://i.imgur.com/YEkbjLC.png", "main": "Extensions/loopyLoop.js" }, { "name": "Shuffle+", "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.", "preview": "https://i.imgur.com/gxbnqSN.png", "main": "Extensions/shuffle+.js" }, { "name": "Trash Bin", "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.", "preview": "https://i.imgur.com/ZFTy5Rm.png", "main": "Extensions/trashbin.js" } ] ================================================ FILE: spicetify.go ================================================ package main import ( "errors" "io" "log" "os" "os/exec" "path/filepath" "runtime" "slices" "strings" "sync" colorable "github.com/mattn/go-colorable" "github.com/pterm/pterm" "github.com/spicetify/cli/src/cmd" spotifystatus "github.com/spicetify/cli/src/status/spotify" "github.com/spicetify/cli/src/utils" "github.com/spicetify/cli/src/utils/isAdmin" ) var ( version string ) var ( flags = []string{} commands = []string{} quiet = false extensionFocus = false appFocus = false styleFocus = false noRestart = false liveRefresh = false bypassAdminCheck = false ) func init() { if runtime.GOOS != "windows" && runtime.GOOS != "darwin" && runtime.GOOS != "linux" { utils.PrintError("Unsupported OS.") os.Exit(1) } if version == "" { version = "Dev" } log.SetFlags(0) // Supports print color output for Windows log.SetOutput(colorable.NewColorableStdout()) // Separates flags and commands for _, v := range os.Args[1:] { if len(v) > 0 && v[0] == '-' { if len(v) > 2 && v[1] != '-' { for _, char := range v[1:] { flags = append(flags, "-"+string(char)) } } else { flags = append(flags, v) } } else { commands = append(commands, v) } } for _, v := range flags { switch v { case "--bypass-admin": bypassAdminCheck = true case "-c", "--config": log.Println(cmd.GetConfigPath()) os.Exit(0) case "-h", "--help": kind := "" if len(commands) > 0 { kind = commands[0] } if kind == "config" { helpConfig() } else { help() } os.Exit(0) case "-v", "--version": log.Println(version) os.Exit(0) case "-e", "--extension": extensionFocus = true liveRefresh = true case "-a", "--app": appFocus = true liveRefresh = true case "-q", "--quiet": quiet = true case "-n", "--no-restart": noRestart = true case "-s", "--style": styleFocus = true liveRefresh = true case "-l", "--live-refresh": extensionFocus = true appFocus = true styleFocus = true liveRefresh = true } } if quiet { log.SetOutput(io.Discard) os.Stdout = nil pterm.DisableOutput() } if isAdmin.Check(bypassAdminCheck) { utils.PrintError("Spicetify should NOT be run with administrator or root privileges") utils.PrintError("Doing so can cause Spotify to show a black/blank window after applying!") utils.PrintError("This happens because Spotify (running as a normal user) can't access files modified with admin privileges") utils.PrintInfo("If you understand the risks and need to continue, you can use the '--bypass-admin' flag.") os.Exit(1) } for i, flag := range flags { if flag == "--bypass-admin" { flags = append(flags[:i], flags[i+1:]...) break } } utils.MigrateConfigFolder() utils.MigrateFolders() cmd.InitConfig(quiet) if len(commands) < 1 { help() cmd.CheckUpdate(version) os.Exit(0) } } func main() { if slices.Contains(commands, "config-dir") { cmd.ShowConfigDirectory() return } // Unchainable commands switch commands[0] { case "config": commands = commands[1:] if len(commands) == 0 { cmd.DisplayAllConfig() } else if len(commands) == 1 { cmd.DisplayConfig(commands[0]) } else { cmd.EditConfig(commands) } return case "color": commands = commands[1:] if len(commands) == 0 { cmd.DisplayColors() } else { cmd.EditColor(commands) } return case "spotify-updates": cmd.InitPaths() commands = commands[1:] if len(commands) == 0 { utils.PrintError("No parameter given. It has to be \"block\" or \"unblock\".") return } param := commands[0] switch param { case "block": cmd.BlockSpotifyUpdates(true) case "unblock": cmd.BlockSpotifyUpdates(false) default: utils.PrintError("Invalid parameter. It has to be \"block\" or \"unblock\".") } return case "path": cmd.InitPaths() commands = commands[1:] path, err := (func() (string, error) { if styleFocus { if len(commands) == 0 { return cmd.ThemeAllAssetsPath() } return cmd.ThemeAssetPath(commands[0]) } else if extensionFocus { if len(commands) == 0 { return cmd.ExtensionAllPath() } return cmd.ExtensionPath(commands[0]) } else if appFocus { if len(commands) == 0 { return cmd.AppAllPath() } return cmd.AppPath(commands[0]) } else { for _, v := range flags { if v != "-e" && v != "-c" && v != "-a" && v != "-s" { return "", errors.New("invalid option\navailable options: -e, -c, -a, -s") } } if len(commands) == 0 && len(flags) == 0 { return utils.GetExecutableDir(), nil } else if commands[0] == "all" { return cmd.AllPaths() } else if commands[0] == "userdata" { return utils.GetSpicetifyFolder(), nil } return "", errors.New("invalid option\navailable options: all, userdata") } })() if err != nil { utils.Fatal(err) } log.Println(path) return case "watch": cmd.InitPaths() var name []string if len(commands) > 1 { name = commands[1:] } var watchGroup sync.WaitGroup if extensionFocus { watchGroup.Add(1) go func(name []string, liveUpdate bool) { defer watchGroup.Done() cmd.WatchExtensions(name, liveUpdate) }(name, liveRefresh) } if appFocus { watchGroup.Add(1) go func(name []string, liveUpdate bool) { defer watchGroup.Done() cmd.WatchCustomApp(name, liveUpdate) }(name, liveRefresh) } if styleFocus { watchGroup.Add(1) go func(liveUpdate bool) { defer watchGroup.Done() cmd.Watch(liveUpdate) }(liveRefresh) } watchGroup.Wait() return } cmd.InitPaths() utils.PrintBold("spicetify v" + version) if slices.Contains(commands, "upgrade") || slices.Contains(commands, "update") { updateStatus := cmd.Update(version) spotifyPath := filepath.Join(cmd.GetSpotifyPath(), "Apps") ex, err := os.Executable() if err != nil { ex = "spicetify" } if updateStatus { spotStat := spotifystatus.Get(spotifyPath) cmds := []string{"backup", "apply"} if !spotStat.IsBackupable() { cmds = append([]string{"restore"}, cmds...) } cmd := exec.Command(ex, cmds...) utils.CmdScanner(cmd) cmd = exec.Command(ex, strings.Join(commands[:], " ")) utils.CmdScanner(cmd) } spotStat := spotifystatus.Get(spotifyPath) if spotStat.IsBackupable() { utils.PrintNote("spicetify is up-to-date! If you ran this because spicetify disappeared after Spotify updated, we'll attempt to fix it for you right now.") cmd.Backup(version, true) cmd.CheckStates() cmd.InitSetting() cmd.Apply(version) if !noRestart { cmd.SpotifyRestart() } } return } else { cmd.CheckUpdate(version) } var shouldRestart bool = false // Chainable commands for _, v := range commands { switch v { case "backup": cmd.Backup(version, slices.Contains(commands, "apply")) case "clear": cmd.Clear() case "apply": cmd.CheckStates() cmd.InitSetting() cmd.Apply(version) shouldRestart = true case "refresh": cmd.CheckStates() cmd.InitSetting() if extensionFocus { cmd.RefreshExtensions() } else if appFocus { cmd.RefreshApps() } else { cmd.RefreshTheme() } case "restore": cmd.Restore() shouldRestart = true case "enable-devtools": cmd.EnableDevTools() shouldRestart = true case "restart": cmd.SpotifyRestart() case "auto": cmd.Auto(version) shouldRestart = true default: utils.Fatal(errors.New(`Command "` + v + `" not found. Run "spicetify -h" for a list of valid commands.`)) } } if !noRestart && !slices.Contains(commands, "restart") && shouldRestart { cmd.SpotifyRestart() } } func help() { utils.PrintBold("spicetify v" + version) log.Println(utils.Bold("USAGE") + "\n" + "spicetify [-q] [-e] [-a] \x1B[4mcommand\033[0m...\n" + "spicetify {-c | --config} | {-v | --version} | {-h | --help}\n\n" + utils.Bold("DESCRIPTION") + "\n" + "Customize Spotify client UI and functionality\n\n" + utils.Bold("CHAINABLE COMMANDS") + ` backup Start backup and preprocessing of app files. apply Apply customization. refresh Refresh the theme's CSS, JS, colors, and assets. Use with flag "-e" to update extensions or with flag "-a" to update custom apps. restore Restore Spotify to original state. clear Clear current backup files. enable-devtools Enable Spotify's developer tools. Press Ctrl + Shift + I (Windows/Linux) or Cmd + Option + I (macOS) in the Spotify client to open. watch Enter watch mode. To update on change, use with any combination of the following flags: "-e" (for extensions), "-a" (for custom apps), "-s" (for the active theme; color.ini, user.css, theme.js, and assets) "-l" (for all of the above) restart Restart Spotify client. ` + utils.Bold("NON-CHAINABLE COMMANDS") + ` spotify-updates Block Spotify updates by patching spotify executable. Accepts "block" or "unblock" as the parameter. path Print path of Spotify's executable, userdata, and more. 1. Print executable path: spicetify path 2. Print userdata path: spicetify path userdata 3. Print all paths: spicetify path all 4. Toggle focus with flags: spicetify path