diff --git a/.github/assets/logo.png b/.github/assets/logo.png deleted file mode 100644 index 6e366eab32..0000000000 Binary files a/.github/assets/logo.png and /dev/null differ diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 615dfde20a..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" - - package-ecosystem: "gomod" - directory: "/" - schedule: - interval: "daily" diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index d3385116fb..0000000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,18 +0,0 @@ -📦 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)" diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml new file mode 100644 index 0000000000..57c99603d4 --- /dev/null +++ b/.github/workflows/build-linux.yml @@ -0,0 +1,44 @@ +name: Build for Linux + +on: + workflow_call: + inputs: + tag: + description: Tag + required: true + type: string + arch: + description: Architecture + required: true + type: string + +jobs: + build-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - name: Build + shell: bash + run: | + set -e + cd build/linux + chmod +x build.sh + ./build.sh "${{ inputs.tag }}" "${{ inputs.arch }}" + + - name: Attest + uses: actions/attest-build-provenance@v1 + with: + subject-path: "build/linux/dist/*" + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: cli-linux-${{ inputs.arch }} + path: "build/linux/dist/*" diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 0000000000..999d06a2d8 --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -0,0 +1,43 @@ +name: Build for macOS + +on: + workflow_call: + inputs: + tag: + description: Tag + required: true + type: string + +jobs: + build-macos: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - shell: bash + run: brew install create-dmg + + - name: Build + shell: bash + run: | + set -e + cd build/macos + chmod +x build.sh + ./build.sh "${{ inputs.tag }}" + + - name: Attest + uses: actions/attest-build-provenance@v1 + with: + subject-path: "build/macos/dist/*" + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: installer-macos + path: "build/macos/dist/*" diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000000..d968b98dff --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,51 @@ +name: Build for Windows + +on: + workflow_call: + inputs: + tag: + description: Tag + required: true + type: string + arch: + description: Architecture + required: true + type: string + +jobs: + build-windows: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - name: Setup Wix + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + dotnet tool install -g wix + wix extension add -g WixToolset.Util.wixext + wix extension add -g WixToolset.UI.wixext + + - name: Build + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + cd .\build\windows + .\build.ps1 -version "${{ inputs.tag }}"-platform "${{ inputs.arch }}" + + - name: Attest + uses: actions/attest-build-provenance@v1 + with: + subject-path: "build/windows/dist/*" + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: installer-windows-${{ inputs.arch }} + path: "build/windows/dist/*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 36b2719d87..0000000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: Build - -on: [pull_request, push] - -jobs: - build: - name: Build - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - 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: - name: Release - strategy: - matrix: - os: ["linux", "darwin", "windows"] - arch: ["amd64", "arm64", "386"] - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') - needs: build - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - 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' }}" >> $GITHUB_ENV - - - name: Is Windows Platform - run: echo "IS_WIN=${{ matrix.os == 'windows' }}" >> $GITHUB_ENV - - - name: Setup Go - uses: actions/setup-go@v5 - 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: 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://vps.itsmeow.dev/spicetify-update - method: GET - - name: Update Winget package - uses: vedantmgoyal2009/winget-releaser@v2 - with: - identifier: Spicetify.Spicetify - installers-regex: '-windows-\w+\.zip$' - token: ${{ secrets.SPICETIFY_WINGET_TOKEN }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index acc935d6c5..0000000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,15 +0,0 @@ -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 diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml deleted file mode 100644 index 3c13645511..0000000000 --- a/.github/workflows/linter.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Code quality - -on: [push, pull_request] - -jobs: - linter: - runs-on: ubuntu-latest - steps: - - name: Checkout the repo - uses: actions/checkout@v4 - - - name: Setup Biome - uses: biomejs/setup-biome@v2 - with: - version: latest - - - name: Run Biome - run: biome ci . --files-ignore-unknown=true diff --git a/.github/workflows/lintpr.yml b/.github/workflows/lintpr.yml deleted file mode 100644 index 41ab79a99a..0000000000 --- a/.github/workflows/lintpr.yml +++ /dev/null @@ -1,21 +0,0 @@ -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@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - disallowScopes: | - [A-Z]+ - subjectPattern: ^(?![A-Z]).+$ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..92c6527f2d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release + +on: + release: + types: [published] + +jobs: + setup: + name: Release + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v3') + outputs: + tag: ${{ steps.set-env.outputs.tag }} + steps: + - id: set-env + name: Set Env + run: echo "tag=${GITHUB_REF#refs/*/v}" >> "$GITHUB_OUTPUT" + + build-linux: + uses: ./.github/workflows/build-linux.yml + permissions: + contents: write + id-token: write + attestations: write + needs: setup + strategy: + matrix: + arch: [amd64, arm64] + with: + tag: ${{ needs.setup.outputs.tag }} + arch: ${{ matrix.arch }} + + build-macos: + uses: ./.github/workflows/build-macos.yml + permissions: + contents: write + id-token: write + attestations: write + needs: setup + with: + tag: ${{ needs.setup.outputs.tag }} + + build-windows: + uses: ./.github/workflows/build-windows.yml + permissions: + contents: write + id-token: write + attestations: write + needs: setup + strategy: + matrix: + arch: [386, amd64, arm64] + with: + tag: ${{ needs.setup.outputs.tag }} + arch: ${{ matrix.arch }} + + release: + runs-on: ubuntu-latest + needs: [setup, build-linux, build-macos, build-windows] + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Attest + uses: actions/attest-build-provenance@v1 + with: + subject-path: "dist/*" + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: "dist/*" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index db291ac26e..0000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Mark stale issues and pull requests - -on: - schedule: - - cron: "20 4 * * *" - -jobs: - stale: - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - - steps: - - uses: actions/stale@v9.0.0 - with: - # Token for the repository. Can be passed in using `{{ secrets.GITHUB_TOKEN }}`. - repo-token: ${{ github.token }} - # The message to post on the issue when tagging it. If none provided, will not mark issues stale. - stale-issue-message: "Marking this issue as stale.\n\nLet someone know if I'm wrong, after all, I'm just a bot!" - # The message to post on the pull request when tagging it. If none provided, will not mark pull requests stale. - stale-pr-message: "Marking this PR as stale.\n\nLet someone know if I'm wrong, after all, I'm just a bot!" - # The message to post on the issue when closing it. If none provided, will not comment when closing an issue. - close-issue-message: "Closing this issue due to inactivity.\n\nLet someone know if I'm wrong, after all, I'm just a bot!" - # The message to post on the pull request when closing it. If none provided, will not comment when closing a pull requests. - close-pr-message: "Closing this PR due to inactivity.\n\nLet someone know if I'm wrong, after all, I'm just a bot!" - # The number of days old an issue or a pull request can be before marking it stale. Set to -1 to never mark issues or pull requests as stale automatically. - days-before-stale: 30 - # The number of days to wait to close an issue or a pull request after it being marked stale. Set to -1 to never close stale issues or pull requests. - days-before-close: -1 - # The label to apply when an issue is stale. - stale-issue-label: "☠️ stale" - # Only issues with all of these labels are checked if stale. Defaults to `[]` (disabled) and can be a comma-separated list of labels. Override "only-labels" option regarding only the issues. - only-issue-labels: "🐛 bug" - # Display some statistics at the end regarding the stale workflow (only when the logs are enabled). - enable-statistics: true diff --git a/.gitignore b/.gitignore index 671d5833e7..c262ba4466 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,3 @@ -# Executables -bin -spicetify-cli -spicetify -*.exe - -# MacOS -.DS_Store - -# Node.js -node_modules -package-lock.json -package.json - -# Logs -install.log - -pnpm-lock.yaml +dist/ +/spicetify +/spicetify.exe diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d38c7e8471..697a0b62e2 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,8 @@ { - "recommendations": ["timonwong.shellcheck", "biomejs.biome", "golang.go", "ms-vscode.powershell"] + "recommendations": [ + "golang.go", + "timonwong.shellcheck", + "ms-vscode.powershell", + "idleberg.applescript", + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index ba5a4229d9..f3fe7c4830 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,9 @@ { - "editor.formatOnSave": true, - "[go]": { - "editor.defaultFormatter": "golang.go" - }, - "[powershell]": { - "editor.defaultFormatter": "ms-vscode.powershell" - }, - "[javascript][typescript][json]": { - "editor.defaultFormatter": "biomejs.biome" - } + "editor.formatOnSave": true, + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[powershell]": { + "editor.defaultFormatter": "ms-vscode.powershell" + } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 7468ea5e22..0000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,120 +0,0 @@ -# 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/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/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/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/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/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/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/spicetify-cli/issues). Create an enhancement suggestion using the provided [feature request template](https://github.com/spicetify/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/spicetify-cli`. -2. Enter the repository directory and build the project. - * Windows - ``` - cd spicetify-cli - go build -o spicetify.exe - ``` - * Linux and MacOS - ``` - cd spicetify-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/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). diff --git a/COPYING b/COPYING new file mode 100644 index 0000000000..e72bfddabc --- /dev/null +++ b/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, 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 +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If 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 convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "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 PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM 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 PROGRAM (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 PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state 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 program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/CustomApps/lyrics-plus/OptionsMenu.js b/CustomApps/lyrics-plus/OptionsMenu.js deleted file mode 100644 index a6d0dea2bb..0000000000 --- a/CustomApps/lyrics-plus/OptionsMenu.js +++ /dev/null @@ -1,326 +0,0 @@ -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" - }) - ) - ) - ); -}); - -const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => { - const items = useMemo(() => { - let sourceOptions = { - none: "None" - }; - - const languageOptions = { - off: "Off", - "zh-hans": "Chinese (Simplified)", - "zh-hant": "Chinese (Traditional)", - ja: "Japanese", - ko: "Korean" - }; - - let modeOptions = {}; - - if (hasTranslation.musixmatch) { - sourceOptions = { - ...sourceOptions, - musixmatchTranslation: "English (Musixmatch)" - }; - } - - 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 = { - hangul: "Hangul", - romaja: "Romaja" - }; - break; - } - case "chinese": { - modeOptions = { - cn: "Simplified Chinese", - hk: "Traditional Chinese (Hong Kong)", - tw: "Traditional Chinese (Taiwan)" - }; - break; - } - } - - return [ - { - desc: "Translation Provider", - key: "translate:translated-lyrics-source", - type: ConfigSelection, - options: sourceOptions, - renderInline: true - }, - { - desc: "Language Override", - key: "translate:detect-language-override", - type: ConfigSelection, - options: languageOptions, - renderInline: true - }, - { - desc: "Display Mode", - key: `translation-mode:${friendlyLanguage}`, - type: ConfigSelection, - options: modeOptions, - renderInline: true - }, - { - desc: "Convert", - key: "translate", - type: ConfigSlider, - trigger: "click", - action: "toggle", - renderInline: true - } - ]; - }, [friendlyLanguage]); - - 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]); - - 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) => { - 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 }) => { - 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: "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" - }) - ) - ) - ) - ) - ); -}); diff --git a/CustomApps/lyrics-plus/Pages.js b/CustomApps/lyrics-plus/Pages.js deleted file mode 100755 index b84307ad17..0000000000 --- a/CustomApps/lyrics-plus/Pages.js +++ /dev/null @@ -1,739 +0,0 @@ -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 }) => { - if (!isActive) { - return text.map(({ word }) => word).join(""); - } - - return text.map(({ word, time }) => { - const isWordActive = position >= startTime; - startTime += time; - return react.createElement( - "span", - { - className: `lyrics-lyricsContainer-Karaoke-Word${isWordActive ? " lyrics-lyricsContainer-Karaoke-WordActive" : ""}`, - style: { - "--word-duration": `${time}ms` - } - }, - 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); - } - - const rawLyrics = Utils.convertParsedToLRC(lyrics); - - 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 }, 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"; - } - - return react.createElement( - "p", - { - className, - style: { - cursor: "pointer", - "--position-index": animationIndex, - "--animation-index": (animationIndex < 0 ? 0 : animationIndex) + 1, - "--blur-index": Math.abs(animationIndex) - }, - key: lineNumber, - dir: "auto", - ref, - onClick: event => { - if (startTime) { - Spicetify.Player.seek(startTime); - } - }, - onContextMenu: event => { - event.preventDefault(); - Spicetify.Platform.ClipboardAPI.copy(rawLyrics) - .then(() => Spicetify.showNotification("Lyrics copied to clipboard")) - .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard")); - } - }, - !isKara ? text : react.createElement(KaraokeLine, { text, startTime, position, isActive }) - ); - }) - ), - 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; - } - } - - const rawLyrics = Utils.convertParsedToLRC(lyrics); - - 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 }, 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; - return react.createElement( - "p", - { - className: `lyrics-lyricsContainer-LyricsLine${i <= activeLineIndex ? " lyrics-lyricsContainer-LyricsLine-active" : ""}`, - style: { - cursor: "pointer" - }, - dir: "auto", - ref: isActive ? activeLineRef : null, - onClick: event => { - if (startTime) { - Spicetify.Player.seek(startTime); - } - }, - onContextMenu: event => { - event.preventDefault(); - Spicetify.Platform.ClipboardAPI.copy(rawLyrics) - .then(() => Spicetify.showNotification("Lyrics copied to clipboard")) - .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard")); - } - }, - !isKara ? text : react.createElement(KaraokeLine, { text, startTime, position, isActive }) - ); - }), - react.createElement("p", { - className: "lyrics-lyricsContainer-LyricsUnsyncedPadding" - }), - react.createElement(CreditFooter, { - provider, - copyright - }), - react.createElement(SearchBar, null) - ); -}); - -const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => { - const rawLyrics = lyrics.map(lyrics => (typeof lyrics.text !== "object" ? lyrics.text : lyrics.text?.props?.children?.[0])).join("\n"); - - return react.createElement( - "div", - { - className: "lyrics-lyricsContainer-UnsyncedLyricsPage" - }, - react.createElement("p", { - className: "lyrics-lyricsContainer-LyricsUnsyncedPadding" - }), - lyrics.map(({ text }) => { - return react.createElement( - "p", - { - className: "lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active", - dir: "auto", - onContextMenu: event => { - event.preventDefault(); - Spicetify.Platform.ClipboardAPI.copy(rawLyrics) - .then(() => Spicetify.showNotification("Lyrics copied to clipboard")) - .catch(() => Spicetify.showNotification("Failed to copy 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" - }) - ) - ); -}); diff --git a/CustomApps/lyrics-plus/PlaybarButton.js b/CustomApps/lyrics-plus/PlaybarButton.js deleted file mode 100644 index 1d3465a648..0000000000 --- a/CustomApps/lyrics-plus/PlaybarButton.js +++ /dev/null @@ -1,48 +0,0 @@ -(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 { - 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(); - } -})(); diff --git a/CustomApps/lyrics-plus/ProviderGenius.js b/CustomApps/lyrics-plus/ProviderGenius.js deleted file mode 100644 index 1e92647669..0000000000 --- a/CustomApps/lyrics-plus/ProviderGenius.js +++ /dev/null @@ -1,133 +0,0 @@ -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: encodeURIComponent(`${title} ${info.artist}`) }); - 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 }; -})(); diff --git a/CustomApps/lyrics-plus/ProviderMusixmatch.js b/CustomApps/lyrics-plus/ProviderMusixmatch.js deleted file mode 100644 index f18522e543..0000000000 --- a/CustomApps/lyrics-plus/ProviderMusixmatch.js +++ /dev/null @@ -1,191 +0,0 @@ -const ProviderMusixmatch = (() => { - const headers = { - authority: "apic-desktop.musixmatch.com", - cookie: "x-mxm-token-guid=" - }; - - 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 - }; - - 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 - }; - } - - return body; - } - - 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 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, - text - }; - }); - - return parsedKaraoke; - } - - 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; - } - - return JSON.parse(subtitle.subtitle_body).map(line => ({ - text: line.text || "♪", - startTime: line.time.total * 1000 - })); - } - - 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; - } - return lyrics.split("\n").map(text => ({ text })); - } - - return null; - } - - async function getTranslation(body) { - const track_id = body?.["matcher.track.get"]?.message?.body?.track?.track_id; - if (!track_id) return null; - - const baseURL = - "https://apic-desktop.musixmatch.com/ws/1.1/crowd.track.translations.get?translation_fields_set=minimal&selected_language=en&comment_format=text&format=json&app_id=web-desktop-app-v1.0&"; - - const params = { - track_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; - - if (!result.translations_list?.length) return null; - - return result.translations_list.map(({ translation }) => ({ translation: translation.description, matchedLine: translation.matched_line })); - } - - return { findLyrics, getKaraoke, getSynced, getUnsynced, getTranslation }; -})(); diff --git a/CustomApps/lyrics-plus/ProviderNetease.js b/CustomApps/lyrics-plus/ProviderNetease.js deleted file mode 100644 index d9ec062c5d..0000000000 --- a/CustomApps/lyrics-plus/ProviderNetease.js +++ /dev/null @@ -1,203 +0,0 @@ -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; - const itemId = items.findIndex(val => Utils.normalize(val.album.name) === expectedAlbumName || Math.abs(info.duration - val.duration) < 1000); - 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 }; -})(); diff --git a/CustomApps/lyrics-plus/Providers.js b/CustomApps/lyrics-plus/Providers.js deleted file mode 100644 index ca1cd89c63..0000000000 --- a/CustomApps/lyrics-plus/Providers.js +++ /dev/null @@ -1,183 +0,0 @@ -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/lyrics/v1/track/"; - const id = info.uri.split(":")[2]; - let body; - try { - body = await Spicetify.CosmosAsync.get(baseURL + id); - } catch { - return { error: "Request error", uri: info.uri }; - } - - const lines = body.lines; - if (!lines || !lines.length) { - return { error: "No lyrics", uri: info.uri }; - } - - if (typeof lines[0].time === "number") { - result.synced = lines.map(line => ({ - startTime: line.time, - text: line.words.map(b => b.string).join(" ") - })); - result.unsynced = result.synced; - } else { - result.unsynced = lines.map(line => ({ - text: line.words.map(b => b.string).join(" ") - })); - } - - result.provider = body.provider; - - return result; - }, - - musixmatch: async info => { - const result = { - error: null, - uri: info.uri, - karaoke: null, - synced: null, - unsynced: null, - musixmatchTranslation: 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(); - } - const translation = await ProviderMusixmatch.getTranslation(list); - if ((synced || unsynced) && translation) { - const baseLyrics = synced ?? unsynced; - result.musixmatchTranslation = baseLyrics.map(line => ({ - ...line, - text: translation.find(t => t.matchedLine === line.text)?.translation ?? line.text, - originalText: line.text - })); - } - - 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 (translation) { - result.neteaseTranslation = translation; - } - - 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; - } -}; diff --git a/CustomApps/lyrics-plus/README.md b/CustomApps/lyrics-plus/README.md deleted file mode 100644 index e2b9401298..0000000000 --- a/CustomApps/lyrics-plus/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# 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: Provide unsynced lyrics but with description/insight from artists themselve. - -![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). diff --git a/CustomApps/lyrics-plus/Settings.js b/CustomApps/lyrics-plus/Settings.js deleted file mode 100644 index b1ba00b471..0000000000 --- a/CustomApps/lyrics-plus/Settings.js +++ /dev/null @@ -1,657 +0,0 @@ -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 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 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 => { - 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 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 - } - ], - 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)); - }, - onToggle: (name, value) => { - CONFIG.providers[name].on = value; - localStorage.setItem(`${APP_NAME}:provider:${name}:on`, value); - lyricContainerUpdate?.(); - }, - onTokenChange: (name, value) => { - CONFIG.providers[name].token = value; - localStorage.setItem(`${APP_NAME}:provider:${name}:token`, value); - } - }) - ); - - Spicetify.PopupModal.display({ - title: "Lyrics Plus", - content: configContainer, - isLarge: true - }); -} diff --git a/CustomApps/lyrics-plus/TabBar.js b/CustomApps/lyrics-plus/TabBar.js deleted file mode 100644 index 4a0b6d55ac..0000000000 --- a/CustomApps/lyrics-plus/TabBar.js +++ /dev/null @@ -1,204 +0,0 @@ -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") ?? document.querySelector(".Root__main-view .os-size-observer"); - 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 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 - ) - ); -}); diff --git a/CustomApps/lyrics-plus/Translator.js b/CustomApps/lyrics-plus/Translator.js deleted file mode 100644 index 4b0853afae..0000000000 --- a/CustomApps/lyrics-plus/Translator.js +++ /dev/null @@ -1,146 +0,0 @@ -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) { - this.finished = { - ja: false, - ko: false, - zh: false - }; - - this.applyKuromojiFix(); - this.injectExternals(lang); - this.createTranslator(lang); - } - - includeExternal(url) { - if (CONFIG.visual.translate && !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; - } - } - - /** - * 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)); - } -} diff --git a/CustomApps/lyrics-plus/Utils.js b/CustomApps/lyrics-plus/Utils.js deleted file mode 100644 index 9ffc2fa419..0000000000 --- a/CustomApps/lyrics-plus/Utils.js +++ /dev/null @@ -1,270 +0,0 @@ -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"); - - // 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.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(result, lyricsToTranslate, { state, stateName }) { - const translatedLines = result.split("\n"); - - state[stateName] = []; - - for (let i = 0; i < lyricsToTranslate.length; i++) - state[stateName].push({ - startTime: lyricsToTranslate[i].startTime || 0, - text: this.rubyTextToReact(translatedLines[i]) - }); - }, - 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) { - return lyrics - .map(line => { - if (!line.startTime) return line.text; - return `[${this.formatTime(line.startTime)}]${this.formatTextWithTimestamps(line.text, line.startTime)}`; - }) - .join("\n"); - }, - 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 - } -}; diff --git a/CustomApps/lyrics-plus/conversion.png b/CustomApps/lyrics-plus/conversion.png deleted file mode 100644 index 669124681e..0000000000 Binary files a/CustomApps/lyrics-plus/conversion.png and /dev/null differ diff --git a/CustomApps/lyrics-plus/genius.png b/CustomApps/lyrics-plus/genius.png deleted file mode 100644 index 5fd34cf10f..0000000000 Binary files a/CustomApps/lyrics-plus/genius.png and /dev/null differ diff --git a/CustomApps/lyrics-plus/index.js b/CustomApps/lyrics-plus/index.js deleted file mode 100644 index b6243092ab..0000000000 --- a/CustomApps/lyrics-plus/index.js +++ /dev/null @@ -1,958 +0,0 @@ -// 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 reactDOM = Spicetify.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 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: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") || "hangul", - "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", - "fade-blur": getConfig("lyrics-plus:visual:fade-blur"), - "fullscreen-key": localStorage.getItem("lyrics-plus:visual:fullscreen-key") || "f12", - "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: { - 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.", - 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"), - 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"]); - -const CACHE = {}; - -const emptyState = { - karaoke: null, - synced: null, - unsynced: null, - genius: null, - genius2: null, - currentLyrics: null -}; - -let lyricContainerUpdate; - -const fontSizeLimit = { min: 16, max: 256, step: 4 }; - -const thresholdSizeLimit = { min: 0, max: 100, step: 5 }; - -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, - 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 - }; - this.currentTrackUri = ""; - this.nextTrackUri = ""; - this.availableModes = []; - this.styleVariables = {}; - this.fullscreenContainer = document.createElement("div"); - this.fullscreenContainer.id = "lyrics-fullscreen-container"; - this.mousetrap = new Spicetify.Mousetrap(); - this.containerRef = react.createRef(null); - this.translator = new Translator(CONFIG.visual["translate:detect-language-override"]); - // Cache last state - this.translationProvider = CONFIG.visual["translate:translated-lyrics-source"]; - this.languageOverride = CONFIG.visual["translate:detect-language-override"]; - this.translate = CONFIG.visual.translate; - } - - 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://api.spotify.com/v1/audio-features/${uri.split(":")[2]}`); - 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 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; - CACHE[data.uri] = finalData; - 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 - })); - } - - CACHE[data.uri] = finalData; - return finalData; - } - - CACHE[trackInfo.uri] = finalData; - return finalData; - } - - async fetchLyrics(track, mode = -1) { - this.state.furigana = - this.state.romaji = - this.state.hiragana = - this.state.katakana = - this.state.hangul = - this.state.romaja = - this.state.cn = - this.state.hk = - this.state.tw = - this.state.musixmatchTranslation = - this.state.neteaseTranslation = - null; - 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); - - if (mode !== -1) { - if (CACHE[info.uri]?.[CONFIG.modes[mode]]) { - this.resetDelay(); - this.setState({ ...CACHE[info.uri], isCached }); - { - 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; - } - } - const lyricsState = CACHE[info.uri][CONFIG.modes[mode]]; - if (lyricsState) { - this.state.currentLyrics = this.state[CONFIG.visual["translate:translated-lyrics-source"]] ?? lyricsState; - } - } - this.translateLyrics(); - return; - } - } else { - if (CACHE[info.uri]) { - this.resetDelay(); - this.setState({ ...CACHE[info.uri], isCached }); - { - 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; - } - } - const lyricsState = CACHE[info.uri][CONFIG.modes[mode]]; - if (lyricsState) { - this.state.currentLyrics = this.state[CONFIG.visual["translate:translated-lyrics-source"]] ?? lyricsState; - } - } - this.translateLyrics(); - return; - } - } - - this.setState({ ...emptyState, isLoading: true, isCached: false }); - const resp = await this.tryServices(info, mode); - - 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) { - this.resetDelay(); - this.setState({ ...resp, isLoading: false, isCached }); - } - - this.translateLyrics(); - } - - lyricsSource(mode) { - const lyricsState = this.state[CONFIG.modes[mode]]; - if (!lyricsState) return; - this.state.currentLyrics = this.state[CONFIG.visual["translate:translated-lyrics-source"]] ?? lyricsState; - } - - provideLanguageCode(lyrics) { - if (!lyrics) return; - - if (CONFIG.visual["translate:detect-language-override"] !== "off") return CONFIG.visual["translate:detect-language-override"]; - - return Utils.detectLanguage(lyrics); - } - - async translateLyrics(silent = true) { - function showNotification(timeout) { - if (silent) return; - Spicetify.showNotification("Translating...", false, timeout); - } - - const lyrics = this.state.currentLyrics; - const language = this.provideLanguageCode(lyrics); - - if (!CONFIG.visual.translate || !language || typeof lyrics?.[0].text !== "string") return; - - if (!this.translator?.finished[language.slice(0, 2)]) { - this.translator.injectExternals(language); - this.translator.createTranslator(language); - showNotification(500); - setTimeout(this.translateLyrics.bind(this), 100, false); - return; - } - - // Seemingly long delay so it can be cleared later for accurate timing - showNotification(10000); - for (const params of [ - ["romaji", "spaced", "romaji"], - ["hiragana", "furigana", "furigana"], - ["hiragana", "normal", "hiragana"], - ["katakana", "normal", "katakana"] - ]) { - if (language !== "ja") continue; - Promise.all(lyrics.map(lyric => this.translator.romajifyText(lyric.text, params[0], params[1]))).then(results => { - const result = results.join("\n"); - Utils.processTranslatedLyrics(result, lyrics, { state: this.state, stateName: params[2] }); - showNotification(200); - lyricContainerUpdate?.(); - }); - } - - for (const params of [ - ["hangul", "hangul"], - ["romaja", "romaja"] - ]) { - if (language !== "ko") continue; - Promise.all(lyrics.map(lyric => this.translator.convertToRomaja(lyric.text, params[1]))).then(results => { - const result = results.join("\n"); - Utils.processTranslatedLyrics(result, lyrics, { state: this.state, stateName: params[1] }); - showNotification(200); - lyricContainerUpdate?.(); - }); - } - - for (const params of [ - ["cn", "hk"], - ["cn", "tw"], - ["t", "cn"], - ["t", "hk"], - ["t", "tw"] - ]) { - if (!language.includes("zh") || (language === "zh-hans" && params[0] === "t") || (language === "zh-hant" && params[0] === "cn")) continue; - Promise.all(lyrics.map(lyric => this.translator.convertChinese(lyric.text, params[0], params[1]))).then(results => { - const result = results.join("\n"); - Utils.processTranslatedLyrics(result, lyrics, { state: this.state, stateName: params[1] }); - showNotification(200); - lyricContainerUpdate?.(); - }); - } - } - - 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 }); - } - - 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 = ""; - } - - 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); - }; - - 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.updateVisualOnConfigChange(); - this.forceUpdate(); - }; - - 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); - } - - 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); - } - - componentDidUpdate() { - // Apparently if any of these values are changed, the cached translation will not be updated, hence the need to retranslate - if ( - this.translationProvider !== CONFIG.visual["translate:translated-lyrics-source"] || - this.languageOverride !== CONFIG.visual["translate:detect-language-override"] || - this.translate !== CONFIG.visual.translate - ) { - this.translationProvider = CONFIG.visual["translate:translated-lyrics-source"]; - this.languageOverride = CONFIG.visual["translate:detect-language-override"]; - this.translate = CONFIG.visual.translate; - - this.translateLyrics(false); - - return; - } - - const language = this.provideLanguageCode(this.state.currentLyrics); - - let isTranslated = false; - - switch (language) { - case "zh-hans": - case "zh-hant": { - isTranslated = !!(this.state.cn || this.state.hk || this.state.tw); - break; - } - case "ja": { - isTranslated = !!(this.state.romaji || this.state.furigana || this.state.hiragana || this.state.katakana); - break; - } - case "ko": { - isTranslated = !!(this.state.hangul || this.state.romaja); - break; - } - } - - !isTranslated && this.translateLyrics(); - } - - 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; - let friendlyLanguage; - - const hasTranslation = this.state.neteaseTranslation !== null || this.state.musixmatchTranslation !== null; - - if (mode !== -1) { - this.lyricsSource(mode); - const language = this.provideLanguageCode(this.state.currentLyrics); - friendlyLanguage = language && new Intl.DisplayNames(["en"], { type: "language" }).of(language.split("-")[0])?.toLowerCase(); - showTranslationButton = (friendlyLanguage || hasTranslation) && (mode === SYNCED || mode === UNSYNCED); - const translatedLyrics = this.state[CONFIG.visual[`translation-mode:${friendlyLanguage}`]]; - - 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 - }); - } else if (mode === SYNCED && this.state.synced) { - activeItem = react.createElement(CONFIG.visual["synced-compact"] ? SyncedLyricsPage : SyncedExpandedLyricsPage, { - trackUri: this.state.uri, - lyrics: CONFIG.visual.translate && translatedLyrics ? translatedLyrics : this.state.currentLyrics, - provider: this.state.provider, - copyright: this.state.copyright - }); - } else if (mode === UNSYNCED && this.state.unsynced) { - activeItem = react.createElement(UnsyncedLyricsPage, { - trackUri: this.state.uri, - lyrics: CONFIG.visual.translate && translatedLyrics ? translatedLyrics : this.state.currentLyrics, - provider: this.state.provider, - copyright: this.state.copyright - }); - } 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) - }); - } - } - - 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 - } - }), - react.createElement(AdjustmentsMenu, { mode }), - 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; - } - - 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) { - 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 reactDOM.createPortal(out, this.fullscreenContainer); - if (fadLyricsContainer) return reactDOM.createPortal(out, fadLyricsContainer); - return out; - } -} diff --git a/CustomApps/lyrics-plus/kara.png b/CustomApps/lyrics-plus/kara.png deleted file mode 100644 index 172201ab14..0000000000 Binary files a/CustomApps/lyrics-plus/kara.png and /dev/null differ diff --git a/CustomApps/lyrics-plus/lockin.png b/CustomApps/lyrics-plus/lockin.png deleted file mode 100644 index 350bae1a80..0000000000 Binary files a/CustomApps/lyrics-plus/lockin.png and /dev/null differ diff --git a/CustomApps/lyrics-plus/manifest.json b/CustomApps/lyrics-plus/manifest.json deleted file mode 100644 index 72b93a85bd..0000000000 --- a/CustomApps/lyrics-plus/manifest.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "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", - "Providers.js", - "Pages.js", - "OptionsMenu.js", - "TabBar.js", - "Utils.js", - "Settings.js", - "Translator.js" - ], - "subfiles_extension": ["PlaybarButton.js"] -} diff --git a/CustomApps/lyrics-plus/search.png b/CustomApps/lyrics-plus/search.png deleted file mode 100644 index 3b46ed0608..0000000000 Binary files a/CustomApps/lyrics-plus/search.png and /dev/null differ diff --git a/CustomApps/lyrics-plus/style.css b/CustomApps/lyrics-plus/style.css deleted file mode 100644 index 1a72252c3d..0000000000 --- a/CustomApps/lyrics-plus/style.css +++ /dev/null @@ -1,714 +0,0 @@ -/*! - * 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; -} - -.lyrics-lyricsContainer-Karaoke-Word { - color: var(--lyrics-color-inactive); - transition: var(--word-duration) color ease; -} - -.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; - background-color: initial; - 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%; -} - -@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; - } -} diff --git a/CustomApps/new-releases/Card.js b/CustomApps/new-releases/Card.js deleted file mode 100644 index c731b8b7ca..0000000000 --- a/CustomApps/new-releases/Card.js +++ /dev/null @@ -1,239 +0,0 @@ -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) { - 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}`); - - event.stopPropagation(); - } - - 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" - }) - ) - ) - ) - ); - } -} diff --git a/CustomApps/new-releases/Icons.js b/CustomApps/new-releases/Icons.js deleted file mode 100644 index e9d00bc1f1..0000000000 --- a/CustomApps/new-releases/Icons.js +++ /dev/null @@ -1,101 +0,0 @@ -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" - ) - ); - } -} diff --git a/CustomApps/new-releases/Settings.js b/CustomApps/new-releases/Settings.js deleted file mode 100644 index 9b4f57558d..0000000000 --- a/CustomApps/new-releases/Settings.js +++ /dev/null @@ -1,302 +0,0 @@ -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 - }); -} diff --git a/CustomApps/new-releases/index.js b/CustomApps/new-releases/index.js deleted file mode 100644 index f336911372..0000000000 --- a/CustomApps/new-releases/index.js +++ /dev/null @@ -1,398 +0,0 @@ -// 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() { - return react.createElement( - "section", - { - className: "contentSpacing" - }, - react.createElement( - "div", - { - className: "new-releases-header" - }, - 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 base = await Spicetify.Platform.LibraryAPI.getArtists(); - const artists = await Spicetify.Platform.LibraryAPI.getArtists({ limit: base.totalLength }); - count(true); - return artists.items ?? []; -} - -async function getArtistEverything(artist) { - const { queryArtistDiscographyAll } = Spicetify.GraphQL.Definitions; - const { data, errors } = await Spicetify.GraphQL.Request(queryArtistDiscographyAll, { - 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 CosmosAsync.get("sp://core-collection/unstable/@/list/shows/all?responseFormat=protobufJson"); - return body.item ?? []; -} - -async function getPodcastRelease(uri) { - const body = await CosmosAsync.get(`sp://core-show/v1/shows/${uri}?responseFormat=protobufJson`, { - policy: { list: { link: true, name: true, publishDate: true } } - }); - 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 obj of await getPodcastList()) { - const podcast = obj.showMetadata; - const id = podcast.link.replace("spotify:show:", ""); - - const tracks = await getPodcastRelease(id); - if (!tracks) continue; - - for (const track of tracks) { - const time = new Date(track.episodeMetadata.publishDate * 1000); - - if (today - time.getTime() > limitInMs) { - break; - } - - items.push({ - uri: track.episodeMetadata.link, - title: track.episodeMetadata.name, - artist: { - name: podcast.name, - uri: podcast.link - }, - imageURL: podcast.covers.standardLink, - time, - type: itemTypeStr - }); - } - } - - return items; -} diff --git a/CustomApps/new-releases/manifest.json b/CustomApps/new-releases/manifest.json deleted file mode 100644 index dd63d7d117..0000000000 --- a/CustomApps/new-releases/manifest.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "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"] -} diff --git a/CustomApps/new-releases/style.css b/CustomApps/new-releases/style.css deleted file mode 100644 index 944814e715..0000000000 --- a/CustomApps/new-releases/style.css +++ /dev/null @@ -1,177 +0,0 @@ -.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: -webkit-box; - display: -ms-flexbox; - 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; -} diff --git a/CustomApps/reddit/Card.js b/CustomApps/reddit/Card.js deleted file mode 100644 index 0256990b25..0000000000 --- a/CustomApps/reddit/Card.js +++ /dev/null @@ -1,209 +0,0 @@ -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() - ) - ) - ) - ); - } -} diff --git a/CustomApps/reddit/Icons.js b/CustomApps/reddit/Icons.js deleted file mode 100644 index 9016d724fe..0000000000 --- a/CustomApps/reddit/Icons.js +++ /dev/null @@ -1,105 +0,0 @@ -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" - ) - ); - } -} diff --git a/CustomApps/reddit/OptionsMenu.js b/CustomApps/reddit/OptionsMenu.js deleted file mode 100644 index b18fe60303..0000000000 --- a/CustomApps/reddit/OptionsMenu.js +++ /dev/null @@ -1,87 +0,0 @@ -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: true - }, - 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" - }) - ) - ) - ); -}); diff --git a/CustomApps/reddit/Settings.js b/CustomApps/reddit/Settings.js deleted file mode 100644 index 3848eb21d4..0000000000 --- a/CustomApps/reddit/Settings.js +++ /dev/null @@ -1,175 +0,0 @@ -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; -} diff --git a/CustomApps/reddit/SortBox.js b/CustomApps/reddit/SortBox.js deleted file mode 100644 index 16715c172b..0000000000 --- a/CustomApps/reddit/SortBox.js +++ /dev/null @@ -1,49 +0,0 @@ -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 - }) - ) - ); - } -} diff --git a/CustomApps/reddit/TabBar.js b/CustomApps/reddit/TabBar.js deleted file mode 100644 index 37731c896a..0000000000 --- a/CustomApps/reddit/TabBar.js +++ /dev/null @@ -1,174 +0,0 @@ -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") ?? document.querySelector(".Root__main-view .os-size-observer"); - 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 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 - ) - ); -}); diff --git a/CustomApps/reddit/index.js b/CustomApps/reddit/index.js deleted file mode 100644 index 8af0cdc4f4..0000000000 --- a/CustomApps/reddit/index.js +++ /dev/null @@ -1,380 +0,0 @@ -// 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() { - return react.createElement( - "section", - { - className: "contentSpacing" - }, - react.createElement( - "div", - { - className: "reddit-header" - }, - 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; -} diff --git a/CustomApps/reddit/manifest.json b/CustomApps/reddit/manifest.json deleted file mode 100644 index 2a00bdc44c..0000000000 --- a/CustomApps/reddit/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Reddit", - "icon": "", - "active-icon": "", - "subfiles": ["Card.js", "Icons.js", "OptionsMenu.js", "SortBox.js", "TabBar.js", "Settings.js"] -} diff --git a/CustomApps/reddit/style.css b/CustomApps/reddit/style.css deleted file mode 100644 index 9edb1340c1..0000000000 --- a/CustomApps/reddit/style.css +++ /dev/null @@ -1,193 +0,0 @@ -.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); -} diff --git a/Extensions/autoSkipExplicit.js b/Extensions/autoSkipExplicit.js deleted file mode 100644 index 5956976bf5..0000000000 --- a/Extensions/autoSkipExplicit.js +++ /dev/null @@ -1,31 +0,0 @@ -// NAME: Christian Spotify -// AUTHOR: khanhas -// DESCRIPTION: Auto skip explicit songs. Toggle in Profile menu. - -/// - -(function ChristianSpotify() { - if (!Spicetify.LocalStorage) { - setTimeout(ChristianSpotify, 1000); - return; - } - - 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(); - } - }); -})(); diff --git a/Extensions/autoSkipVideo.js b/Extensions/autoSkipVideo.js deleted file mode 100644 index cfef89070d..0000000000 --- a/Extensions/autoSkipVideo.js +++ /dev/null @@ -1,18 +0,0 @@ -// 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(); - } - }); -})(); diff --git a/Extensions/bookmark.js b/Extensions/bookmark.js deleted file mode 100644 index e4c67e02d6..0000000000 --- a/Extensions/bookmark.js +++ /dev/null @@ -1,604 +0,0 @@ -// 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(); -})(); diff --git a/Extensions/fullAppDisplay.js b/Extensions/fullAppDisplay.js deleted file mode 100644 index c50b1b6481..0000000000 --- a/Extensions/fullAppDisplay.js +++ /dev/null @@ -1,931 +0,0 @@ -// 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 } = 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; -} -#fad-progress-container { - width: 100%; - display: flex; - align-items: center; -} -#fad-progress { - width: 100%; - height: 6px; - border-radius: 6px; - background-color: #ffffff50; - overflow: hidden; -} -#fad-progress-inner { - height: 100%; - border-radius: 6px; - background-color: #ffffff; - box-shadow: 4px 0 12px rgba(0, 0, 0, 0.8); -} -#fad-duration { - margin-left: 10px; -} -#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; -} -#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; - min-width: 400px; - max-width: 400px; - align-items: center; -} -#fad-status.active { - margin-top: 20px; -} -#fad-controls { - display: flex; - margin-right: 10px; -} -#fad-elapsed { - min-width: 52px; -}`, - ` -#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-elapsed { - min-width: 56px; - margin-right: 10px; - text-align: right; -}` - ]; - - 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; - margin-left: 50px; -}`, - "" - ]; - 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 [value, setValue] = useState(Spicetify.Player.getProgress()); - useEffect(() => { - const update = ({ data }) => setValue(data); - Spicetify.Player.addEventListener("onprogress", update); - return () => Spicetify.Player.removeEventListener("onprogress", update); - }); - const duration = Spicetify.Platform.PlayerAPI._state.duration; - return react.createElement( - "div", - { id: "fad-progress-container" }, - react.createElement("span", { id: "fad-elapsed" }, Spicetify.Player.formatTime(value)), - react.createElement( - "div", - { id: "fad-progress" }, - react.createElement("div", { - id: "fad-progress-inner", - style: { - width: `${(value / duration) * 100}%` - } - }) - ), - 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 || "" - }); - 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 - }); - }; - 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 queue => { - queue = queue.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); -})(); diff --git a/Extensions/keyboardShortcut.js b/Extensions/keyboardShortcut.js deleted file mode 100644 index 4f75cf3784..0000000000 --- a/Extensions/keyboardShortcut.js +++ /dev/null @@ -1,391 +0,0 @@ -// 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") }, - - // 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 ('g') or bottom ('Shift+g') of the page - 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.prev() }, - "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"); - } - - 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; -} diff --git a/Extensions/loopyLoop.js b/Extensions/loopyLoop.js deleted file mode 100644 index 5aec1f393d..0000000000 --- a/Extensions/loopyLoop.js +++ /dev/null @@ -1,131 +0,0 @@ -// 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 bar = document.querySelector(".playback-bar .progress-bar"); - if (!(bar && Spicetify.React)) { - 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 wrapper = document.createElement("div"); - Spicetify.ReactDOM.render( - Spicetify.React.createElement( - Spicetify.ReactComponent.MenuItem, - { - onClick: () => { - contextMenu.hidden = true; - callback?.(); - } - }, - title - ), - wrapper - ); - - return wrapper; - } - - 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; - }); - - bar.oncontextmenu = event => { - const { x, width } = bar.firstElementChild.getBoundingClientRect(); - mouseOnBarPercent = (event.clientX - x) / width; - contextMenu.style.transform = `translate(${event.clientX}px,${event.clientY - contextMenuHeight}px)`; - contextMenu.hidden = false; - event.preventDefault(); - }; -})(); diff --git a/Extensions/popupLyrics.js b/Extensions/popupLyrics.js deleted file mode 100644 index 61cdfe0d68..0000000000 --- a/Extensions/popupLyrics.js +++ /dev/null @@ -1,1076 +0,0 @@ -// 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); - num = null; - } - }; -} else { - PopupLyrics(); -} - -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); - } - }; - - 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/lyrics/v1/track/"; - const id = info.uri.split(":")[2]; - const body = await CosmosAsync.get(baseURL + id); - - const lines = body.lines; - if (!lines || !lines.length || typeof lines[0].time !== "number") { - return { error: "No lyrics" }; - } - - const lyrics = lines.map(a => ({ - startTime: a.time / 1000, - text: a.words.map(b => b.string).join(" ") - })); - - 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 }; - } - }; - - 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. Follow instructions on Spicetify Docs.`, - 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." - } - }, - 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() { - 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 - }; - - for (const name of userConfigs.servicesOrder) { - const service = userConfigs.services[name]; - if (!service.on) continue; - sharedData = { lyrics: [] }; - - try { - const data = await service.call(info); - console.log(data); - sharedData = data; - 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 workerIsRunning = null; - 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) { - if (!workerIsRunning) { - worker.postMessage("popup-lyric-request-update"); - workerIsRunning = true; - } - } else { - if (workerIsRunning) { - worker.postMessage("popup-lyric-stop-update"); - workerIsRunning = false; - } - - 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(); - if (!configContainer) { - configContainer = document.createElement("div"); - configContainer.id = "popup-config-container"; - const style = document.createElement("style"); - style.innerHTML = ` -.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), .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; -} -#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%; - margin-top: 10px; - 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 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(); - } - - 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(); - } - - function tokenChangeCallback(el, inputEl) { - const newVal = inputEl.value; - const id = el.dataset.id; - userConfigs.services[id].token = newVal; - LocalStorage.set(`popup-lyrics:services:${id}:token`, newVal); - updateTrack(); - } - - for (const name of userConfigs.servicesOrder) { - userConfigs.services[name].element = createServiceOption(name, userConfigs.services[name], switchCallback, posCallback, tokenChangeCallback); - } - stackServiceElements(); - - configContainer.append(style, optionHeader, smooth, center, cover, blurSize, fontSize, ratio, delay, 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; - } - - function createServiceOption(id, defaultVal, switchCallback, posCallback, tokenCallback) { - const name = id.replace(/^./, c => c.toUpperCase()); - - const container = document.createElement("div"); - container.dataset.id = id; - container.innerHTML = ` -
    -

    ${name}

    -
    - - - -
    -
    -${defaultVal.desc}`; - - if (defaultVal.token !== undefined) { - const input = document.createElement("input"); - input.placeholder = `Place your ${id} token here`; - input.value = defaultVal.token; - input.onchange = () => tokenCallback(container, input); - container.append(input); - } - - 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; - } -} diff --git a/Extensions/shuffle+.js b/Extensions/shuffle+.js deleted file mode 100755 index 3f012ef6f1..0000000000 --- a/Extensions/shuffle+.js +++ /dev/null @@ -1,633 +0,0 @@ -// 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.CosmosAsync.get(`sp://core-playlist/v1/playlist/spotify:playlist:${uri}/rows`, { - policy: { link: true, playable: true } - }); - return res.rows.filter(track => track.playable).map(track => track.link); - } - - function searchFolder(rows, uri) { - for (const r of rows) { - if (r.type !== "folder" || !r.rows) continue; - - if (r.link === uri) return r; - - const found = searchFolder(r.rows, uri); - if (found) return found; - } - } - - async function fetchFolderTracks(uri) { - const res = await Spicetify.CosmosAsync.get("sp://core-playlist/v1/rootlist", { - policy: { folder: { rows: true, link: true } } - }); - - const requestFolder = searchFolder(res.rows, uri); - if (!requestFolder) throw "Cannot find folder"; - - const requestPlaylists = []; - async function fetchNested(folder) { - if (!folder.rows) return; - - for (const i of folder.rows) { - if (i.type === "playlist") requestPlaylists.push(await fetchPlaylistTracks(i.link.split(":")[2])); - 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.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) { - const { queryArtistDiscographyAll } = Spicetify.GraphQL.Definitions; - // Definition from older Spotify version - const queryArtistOverview = { - name: "queryArtistOverview", - operation: "query", - sha256Hash: "35648a112beb1794e39ab931365f6ae4a8d45e65396d641eeda94e4003d41497", - 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); - } - } -})(); diff --git a/Extensions/trashbin.js b/Extensions/trashbin.js deleted file mode 100644 index ec2478535d..0000000000 --- a/Extensions/trashbin.js +++ /dev/null @@ -1,398 +0,0 @@ -// 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"); - 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("Export", "Copy all items in trashbin to clipboard, manually save 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 exportItems() { - const data = { - songs: trashSongList, - artists: trashArtistList - }; - Spicetify.Platform.ClipboardAPI.copy(JSON.stringify(data)); - Spicetify.showNotification("Copied to clipboard"); - } - - 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(); - } -})(); diff --git a/Extensions/webnowplaying.js b/Extensions/webnowplaying.js deleted file mode 100644 index 0e1777bc61..0000000000 --- a/Extensions/webnowplaying.js +++ /dev/null @@ -1,362 +0,0 @@ -// 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)}`; -} diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 8000a6faac..0000000000 --- a/LICENSE +++ /dev/null @@ -1,504 +0,0 @@ - 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! diff --git a/README.md b/README.md index a6f605026f..bcc7938ed8 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,94 @@ --- -Command-line tool to customize the official Spotify client. -Supports Windows, MacOS and Linux. +## Setup -img +Note: On **Windows**, only use `pwsh` as shell! (not `cmd`, not `powershell`) -### Features +### Part 1: Installation -- Change colors whole UI -- Inject CSS for advanced customization -- Inject Extensions (Javascript script) to extend functionalities, manipulate UI and control player. -- Inject Custom apps -- Remove bloated components to improve performance +0. First and foremost install the build dependencies: + [Git](https://git-scm.com/downloads) and [Go](https://go.dev/doc/install) +1. Clone this repo: `git clone github.com/spicetify/cli -b next && cd cli` +2. Run `go build`, this will create a new `spicetify` executable in the working + directory +3. Run `./spicetify init` to initialize the spicetify setup, this only needs to + be done once. If the command fails, try running it in an elevated shell (as + Administrator) +4. [optional] Add the `spicetify` executable to your PATH variable for ease of + access. + - On **Windows**, run the following in pwsh: + ```pwsh + $user = [EnvironmentVariableTarget]::User + $path = [Environment]::GetEnvironmentVariable('PATH', $user) + $path = "$path;$env:LOCALAPPDATA\spicetify\bin" + [Environment]::SetEnvironmentVariable('PATH', $path, $user) + ``` + - On a default **macOS** installation, run the following: + ```zsh + echo "$HOME/Library/Application Support/spicetify/bin" >> /etc/paths + ``` + - On other platforms, you can perform a simple search on how to set the PATH + environment variable -### Links +### Part 2: Patching -- [Installation](https://spicetify.app/docs/getting-started) -- [Basic Usage](https://spicetify.app/docs/getting-started#basic-usage) -- [FAQ](https://spicetify.app/docs/faq) +5. Run `spicetify sync` to download and install the latest + [hooks](https://github.com/spicetify/hooks) +6. Run `spicetify apply` to patch the Spotify desktop client, this needs only be + done when using spicetify for the first time or when the Spotify client + updates (and reverts all the patches). + +You can always revert this by running `spicetify fix`. + +### Part 3: Managing modules + +You can further improve your experience by installing modules: + +```sh +spicetify pkg install stdlib@0.2.2 https://github.com/spicetify/modules/releases/download/2024-07-18/stdlib@0.2.2+sp-1.2.40-cm-1906ea8d2e9.zip +spicetify pkg install palette-manager@0.2.1 https://github.com/spicetify/modules/releases/download/2024-07-18/palette-manager@0.2.1+sp-1.2.40-cm-1906ea8d2e9.zip +spicetify pkg install Delusoire.marketplace@0.1.3 https://github.com/Delusoire/bespoke-modules/releases/download/2024-07-18/Delusoire.marketplace@0.1.3+sp-1.2.40-cm-1906ea8d2e9.zip +``` + +And enabling them: + +```sh +spicetify pkg enable stdlib@0.2.2 +spicetify pkg enable palette-manager@0.2.1 +spicetify pkg enable Delusoire.marketplace@0.1.3 +``` + +## Caveats + +If your Spotify installation is somewhat unusual, you must manually specify the +paths to the Spotify data and Spotify config folders. You can do that by +creating a `config.yaml` file and adding a +`spotify-data-path: path/to/spotify/data/` (and optionally a +`spotify-config-path: path/to/spotify/config/` for more advanced dev workflows) +Furthermore, if the Spotify folder is Frozen (like the Microsoft Store version +of Spotify), you must tell spicetify to use mirror mode. For the Microsoft Store +version of Spotify, this would be enough: + +``` +$configPath = "$env:LOCALAPPDATA\spicetify\config.yaml" +$spotifyPackage = Get-AppxPackage | Where-Object -Property Name -Eq "SpotifyAB.SpotifyMusic" +"mirror: true" >> $configPath +"spotify-data-path: $($spotifyPackage.InstallLocation)" >> $configPath +"spotify-exec-path: $env:LOCALAPPDATA\Microsoft\WindowsApps\Spotify.exe" >> $configPath +"spotify-config-path: $env:LOCALAPPDATA\Packages\$($spotifyPackage.PackageFamilyName)\LocalState\Spotify\" >> $configPath +``` + +## License + +GPLv3. See [COPYING](COPYING). + +## Advanced Usage + +`spicetify daemon [action]` + +`spicetify pkg action` + +`spicetify update on|off` + +// TODO diff --git a/Themes/SpicetifyDefault/color.ini b/Themes/SpicetifyDefault/color.ini deleted file mode 100644 index a57371018c..0000000000 --- a/Themes/SpicetifyDefault/color.ini +++ /dev/null @@ -1,124 +0,0 @@ -; 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 - diff --git a/Themes/SpicetifyDefault/user.css b/Themes/SpicetifyDefault/user.css deleted file mode 100644 index 60ee15d287..0000000000 --- a/Themes/SpicetifyDefault/user.css +++ /dev/null @@ -1,180 +0,0 @@ -: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); -} diff --git a/archive/tarball.go b/archive/tarball.go new file mode 100644 index 0000000000..fff3c8c7d0 --- /dev/null +++ b/archive/tarball.go @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package archive + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" +) + +func UnTarGZ(r io.Reader, dest string) error { + gzipReader, err := gzip.NewReader(r) + if err != nil { + return err + } + defer gzipReader.Close() + + tarReader := tar.NewReader(gzipReader) + + os.MkdirAll(dest, 0755) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + tarEntryDest := filepath.Join(dest, header.Name) + + switch header.Typeflag { + case tar.TypeDir: + if err := os.Mkdir(tarEntryDest, 0755); err != nil { + return err + } + case tar.TypeReg: + tarEntryFile, err := os.Create(tarEntryDest) + if err != nil { + return err + } + if _, err := io.Copy(tarEntryFile, tarReader); err != nil { + return err + } + tarEntryFile.Close() + } + } + + return nil +} diff --git a/archive/zipball.go b/archive/zipball.go new file mode 100644 index 0000000000..eee52f9371 --- /dev/null +++ b/archive/zipball.go @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package archive + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// pasta from https://stackoverflow.com/a/24792688 +func UnZip(r *zip.Reader, dest string) error { + os.MkdirAll(dest, 0755) + + // Closure to address file descriptors issue with all the deferred .Close() methods + extractAndWriteFile := func(f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + defer func() { + if err := rc.Close(); err != nil { + panic(err) + } + }() + + path := filepath.Join(dest, f.Name) + + // Check for ZipSlip (Directory traversal) + if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", path) + } + + if f.FileInfo().IsDir() { + os.MkdirAll(path, f.Mode()) + } else { + os.MkdirAll(filepath.Dir(path), f.Mode()) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + defer func() { + if err := f.Close(); err != nil { + panic(err) + } + }() + + _, err = io.Copy(f, rc) + if err != nil { + return err + } + } + return nil + } + + for _, f := range r.File { + err := extractAndWriteFile(f) + if err != nil { + return err + } + } + + return nil +} diff --git a/biome.json b/biome.json deleted file mode 100644 index 6a38d87d0d..0000000000 --- a/biome.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.6.2/schema.json", - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "suspicious": { - "noExplicitAny": "off" - } - } - }, - "formatter": { - "enabled": true, - "formatWithErrors": true, - "indentStyle": "tab", - "indentWidth": 2, - "lineWidth": 150 - }, - "javascript": { - "formatter": { - "trailingComma": "none", - "arrowParentheses": "asNeeded" - } - }, - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true, - "defaultBranch": "master" - } -} diff --git a/build/linux/build.sh b/build/linux/build.sh new file mode 100644 index 0000000000..b1b620db19 --- /dev/null +++ b/build/linux/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -e + +version=$1 +arch=$2 + +GOARCH="$arch" go build -C ../../ -o "./build/linux/dist/cli-$version-linux-$arch" -ldflags "-X main.version=$version" + +#TODO: make AppImage diff --git a/build/macos/build.sh b/build/macos/build.sh new file mode 100644 index 0000000000..5c03b3d23e --- /dev/null +++ b/build/macos/build.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env sh +set -e + +version=$1 + +mkdir ./Volume +osacompile -x -o ./Volume/Spicetify.app main.applescript +rm ./Volume/Spicetify.app/Contents/Resources/applet.icns +cp ./installer/AppIcon.icns ./Volume/Spicetify.app/Contents/Resources/AppIcon.icns + +GOARCH="amd64" go build -C ../../ -o ./build/macos/spicetify-amd64 -ldflags "-X main.version=$version" +GOARCH="arm64" go build -C ../../ -o ./build/macos/spicetify-arm64 -ldflags "-X main.version=$version" +mkdir -p ./dist/ +lipo -create -output "./dist/cli-$version-macos" ./spicetify-amd64 ./spicetify-arm64 +mkdir -p ./Volume/Spicetify.app/Contents/MacOS/bin/ +cp "./dist/cli-$version-macos" ./Volume/Spicetify.app/Contents/MacOS/bin/spicetify + +plutil -replace CFBundleName -string "Spicetify" ./Volume/Spicetify.app/Contents/Info.plist +plutil -replace CFBundleIconFile -string AppIcon.icns ./Volume/Spicetify.app/Contents/Info.plist +plutil -replace CFBundleURLTypes -xml 'CFBundleURLNameSpicetifyCFBundleURLSchemesspicetify' ./Volume/Spicetify.app/Contents/Info.plist + +# plutil -remove NSAppleEventsUsageDescription ./Volume/Spicetify.app/Contents/Info.plist +# plutil -remove NSAppleMusicUsageDescription ./Volume/Spicetify.app/Contents/Info.plist +# plutil -remove NSCalendarsUsageDescription ./Volume/Spicetify.app/Contents/Info.plist +# plutil -remove NSCameraUsageDescription ./Volume/Spicetify.app/Contents/Info.plist +# plutil -remove NSContactsUsageDescription ./Volume/Spicetify.app/Contents/Info.plist +# plutil -remove NSHomeKitUsageDescription ./Volume/Spicetify.app/Contents/Info.plist +# plutil -remove NSMicrophoneUsageDescription ./Volume/Spicetify.app/Contents/Info.plist +# plutil -remove NSPhotoLibraryUsageDescription ./Volume/Spicetify.app/Contents/Info.plist +# plutil -remove NSRemindersUsageDescription ./Volume/Spicetify.app/Contents/Info.plist +# plutil -remove NSSiriUsageDescription ./Volume/Spicetify.app/Contents/Info.plist +# plutil -remove NSSystemAdministrationUsageDescription ./Volume/Spicetify.app/Contents/Info.plist + +codesign --deep --force --sign - --timestamp=none ./Volume/Spicetify.app + +create-dmg \ + --volname "Spicetify" \ + --volicon "./installer/VolumeIcon.icns" \ + --background "./installer/banner.png" \ + --window-pos 200 120 \ + --window-size 660 400 \ + --icon-size 160 \ + --icon "Spicetify.app" 180 170 \ + --hide-extension "Spicetify.app" \ + --app-drop-link 480 170 \ + "./dist/installer-$version-macos.dmg" ./Volume/ diff --git a/build/macos/installer/AppIcon.icns b/build/macos/installer/AppIcon.icns new file mode 100644 index 0000000000..556a20e90c Binary files /dev/null and b/build/macos/installer/AppIcon.icns differ diff --git a/build/macos/installer/VolumeIcon.icns b/build/macos/installer/VolumeIcon.icns new file mode 100644 index 0000000000..e0d6705b41 Binary files /dev/null and b/build/macos/installer/VolumeIcon.icns differ diff --git a/build/macos/installer/banner.png b/build/macos/installer/banner.png new file mode 100644 index 0000000000..d21332bd4f Binary files /dev/null and b/build/macos/installer/banner.png differ diff --git a/build/macos/main.applescript b/build/macos/main.applescript new file mode 100644 index 0000000000..f9cd41a7c1 --- /dev/null +++ b/build/macos/main.applescript @@ -0,0 +1,86 @@ +on ensureLineInFileIfExists(filePath, lineToAdd) + set ok to false + try + set fileAlias to POSIX file filePath as alias + local fileDescriptor + set fileDescriptor to open for access fileAlias with write permission + try + set lns to paragraphs of (read fileDescriptor) + repeat with ln in lns + if ln is lineToAdd then + set ok to true + exit repeat + end if + end repeat + if ok is false then + write return & lineToAdd & return to fileDescriptor starting at eof + set ok to true + end if + end try + close access fileDescriptor + end try + return ok +end findLineInFile + +on addLineToShellConfigs(lineToAdd) + set homeFolder to POSIX path of (path to home folder) + set configFiles to {homeFolder & ".bash_profile", homeFolder & ".zshrc"} + + repeat with configFile in configFiles + ensureLineInFileIfExists(configFile, lineToAdd) + end repeat +end addLineToShellConfigs + +on launchAgentExists(agentName) + try + do shell script "launchctl list | grep " & quoted form of agentName + return true + on error + return false + end try +end launchAgentExists + +on createLaunchAgent(plistPath, agentName, binPath) + set plistPathQ to quoted form of plistPath + do shell script "plutil -create xml1 " & plistPathQ + do shell script "plutil -insert Label -string " & quoted form of launchAgentName & " " & plistPathQ + do shell script "plutil -insert ProgramArguments -array -string " & quoted form of binPath & " -string daemon " & plistPathQ + do shell script "plutil -insert RunAtLoad -bool true " & plistPathQ +end createLaunchAgent + +on setupEnvironment(binFolder, binPath, launchAgentName) + set homeFolder to POSIX path of (path to home folder) + + set exportString to "export PATH=" & quote & binFolder & ":$PATH" & quote & " # Added by Spicetify" + addLineToShellConfigs(exportString) + + set launchAgentsFolder to homeFolder & "Library/LaunchAgents/" + set plistPath to launchAgentsFolder & launchAgentName & ".plist" + if not launchAgentExists(launchAgentName) then + createLaunchAgent(plistPath, launchAgentName, binPath) + do shell script "launchctl load -w " & quoted form of plistPath + do shell script quoted form of binPath & " init" + end if +end setupEnvironment + +on open location input + set dirname to POSIX path of (path to me) + set binFolder to dirname & "Contents/MacOS/bin/" + set binPath to binFolder & "spicetify" + + do shell script (quoted form of binPath) & " protocol " & (quoted form of input) +end open location + +on run + set dirname to POSIX path of (path to me) + set binFolder to dirname & "Contents/MacOS/bin/" + set binPath to binFolder & "spicetify" + + setupEnvironment(binFolder, binPath, "app.spicetify.daemon") + + do shell script "open -a Terminal " & quoted form of binPath + -- tell application "Terminal" + -- activate + -- do script quoted form of binPath + -- end tell +end run diff --git a/build/windows/bin/st-daemon.xml b/build/windows/bin/st-daemon.xml new file mode 100644 index 0000000000..b629a1a5d8 Binary files /dev/null and b/build/windows/bin/st-daemon.xml differ diff --git a/build/windows/build.ps1 b/build/windows/build.ps1 new file mode 100644 index 0000000000..3d4987f90b --- /dev/null +++ b/build/windows/build.ps1 @@ -0,0 +1,25 @@ +[CmdletBinding()] +param ( + [Parameter( + Mandatory = $true + )] + [string]$version, + + [Parameter( + Mandatory = $true + )] + [ValidateSet('amd64', 'arm64', '386')] + [string]$platform +) + +$ErrorActionPreference = 'Stop' + +mkdir dist + +$env:GOARCH = $platform +go build -C ..\..\ -o .\build\windows\dist\cli-$version-windows-$architecture.exe -ldflags "-X main.version=$version" + +Copy-Item .\dist\cli-$version-windows-$architecture.exe .\bin\spicetify.exe + +$arch = $platform -replace 'amd64', 'x64' -replace '386', 'x86' +wix build -arch $arch -d ProductVersion=$version -d Platform=$arch -ext WixToolset.Util.wixext -ext WixToolset.UI.wixext .\installer.wxs -o .\dist\installer-$version-windows-$platform.msi diff --git a/build/windows/installer.wxs b/build/windows/installer.wxs new file mode 100644 index 0000000000..d0d6d365c9 --- /dev/null +++ b/build/windows/installer.wxs @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/windows/installer/spicetify.ico b/build/windows/installer/spicetify.ico new file mode 100644 index 0000000000..8b9bb22f80 Binary files /dev/null and b/build/windows/installer/spicetify.ico differ diff --git a/cmd/apply.go b/cmd/apply.go new file mode 100644 index 0000000000..79dfbf3774 --- /dev/null +++ b/cmd/apply.go @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "archive/zip" + "fmt" + "os" + "path/filepath" + "spicetify/archive" + "spicetify/link" + "spicetify/paths" + "strings" + + "github.com/spf13/cobra" +) + +var applyCmd = &cobra.Command{ + Use: "apply", + Short: "Apply spicetify patches on Spotify", + Run: func(cmd *cobra.Command, args []string) { + if err := execApply(); err != nil { + fmt.Println(err) + } + }, +} + +func getApps() (src string, dest string) { + src = paths.GetSpotifyAppsPath(spotifyDataPath) + if mirror { + dest = filepath.Join(paths.ConfigPath, "apps") + } else { + dest = src + } + return src, dest +} + +func extractSpa(spa string, destFolder string) error { + basename := filepath.Base(spa) + extractDest := filepath.Join(destFolder, strings.TrimSuffix(basename, ".spa")) + fmt.Println("Extracting", spa, "->", extractDest) + r, err := zip.OpenReader(spa) + if err != nil { + return err + } + if err := archive.UnZip(&r.Reader, extractDest); err != nil { + return err + } + if err := r.Close(); err != nil { + panic(err) + } + if !mirror { + spaBak := spa + ".bak" + fmt.Println("Moving", spa, "->", spaBak) + if err := os.Rename(spa, spaBak); err != nil { + return err + } + } + return nil +} + +func patchFile(path string, patch func(string) string) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + + content := patch(string(raw)) + + return os.WriteFile(path, []byte(content), 0700) +} + +func patchIndexHtml(destXpuiPath string) error { + fmt.Println("Patching xpui/index.html") + return patchFile(filepath.Join(destXpuiPath, "index.html"), func(s string) string { + return strings.Replace(s, ``, ``, 1) + }) +} + +func linkFiles(destXpuiPath string) error { + folders := []string{"hooks", "modules", "store"} + for _, folder := range folders { + folderSrcPath := filepath.Join(paths.ConfigPath, folder) + folderDestPath := filepath.Join(destXpuiPath, folder) + fmt.Println("Linking", folderDestPath, "->", folderSrcPath) + os.Remove(folderDestPath) + if err := link.Create(folderSrcPath, folderDestPath); err != nil { + return err + } + } + return nil +} + +func execApply() error { + fmt.Println("Initializing spicetify") + src, dest := getApps() + + spa := filepath.Join(src, "xpui.spa") + if err := extractSpa(spa, dest); err != nil { + return err + } + + destXpuiPath := filepath.Join(dest, "xpui") + if err := patchIndexHtml(destXpuiPath); err != nil { + return err + } + return linkFiles(destXpuiPath) +} + +func init() { + rootCmd.AddCommand(applyCmd) +} diff --git a/cmd/daemon.go b/cmd/daemon.go new file mode 100644 index 0000000000..664602d8b3 --- /dev/null +++ b/cmd/daemon.go @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/http/cookiejar" + "net/http/httputil" + "net/url" + "spicetify/paths" + "strings" + + "github.com/fsnotify/fsnotify" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/net/publicsuffix" + + "github.com/gorilla/websocket" +) + +var ( + DaemonAddr = "localhost:7967" + AllowedOrigin = "https://xpui.app.spotify.com" + daemon bool +) + +var daemonCmd = &cobra.Command{ + Use: "daemon", + Short: "Run daemon", + Run: func(cmd *cobra.Command, args []string) { + if daemon { + fmt.Println("Starting daemon") + startDaemon() + } + }, +} + +var daemonStartCmd = &cobra.Command{ + Use: "start", + Short: "Start daemon", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Starting daemon") + startDaemon() + }, +} + +var daemonEnableCmd = &cobra.Command{ + Use: "enable", + Short: "Enable daemon", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Enabling daemon") + daemon = true + viper.Set("daemon", daemon) + viper.WriteConfig() + }, +} + +var daemonDisableCmd = &cobra.Command{ + Use: "disable", + Short: "Disable daemon", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Disabling daemon") + daemon = false + viper.Set("daemon", daemon) + viper.WriteConfig() + }, +} + +func init() { + cobra.OnInitialize(func() { + viper.SetDefault("daemon", true) + daemon = viper.GetBool("daemon") + }) + + rootCmd.AddCommand(daemonCmd) + + daemonCmd.AddCommand(daemonStartCmd, daemonEnableCmd, daemonDisableCmd) + + viper.SetDefault("daemon", false) +} + +func startDaemon() { + c := make(chan struct{}) + + viper.OnConfigChange(func(in fsnotify.Event) { + daemon = viper.GetBool("daemon") + if !daemon { + close(c) + } + }) + go viper.WatchConfig() + + go func() { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Panicln(err) + } + defer watcher.Close() + + log.Println("Watcher: watching:", paths.GetSpotifyAppsPath(spotifyDataPath)) + if err := watcher.Add(paths.GetSpotifyAppsPath(spotifyDataPath)); err != nil { + log.Panicln(err) + } + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + continue + } + log.Println("Watcher: event:", event) + if event.Has(fsnotify.Create) { + if strings.HasSuffix(event.Name, "xpui.spa") { + if err := execApply(); err != nil { + log.Println(err) + } + } + } + case err, ok := <-watcher.Errors: + if !ok { + continue + } + log.Println("Watcher: !:", err) + } + } + }() + + go func() { + setupProxy() + setupWebSocket() + err := http.ListenAndServe(DaemonAddr, nil) + log.Panicln(err) + }() + + <-c +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + // TODO: improve security + return true + }, +} + +func setupWebSocket() { + http.HandleFunc("/rpc", func(w http.ResponseWriter, r *http.Request) { + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Println("WebSocket: upgrade:", err) + return + } + defer c.Close() + + for { + _, p, err := c.ReadMessage() + if err != nil { + log.Println("WebSocket: !read:", err) + break + } + + incoming := string(p) + log.Println("recv:", incoming) + res, err := HandleProtocol(incoming) + if err != nil { + log.Println("WebSocket: !handle:", err) + } + if res != "" { + c.WriteMessage(websocket.TextMessage, []byte(res)) + } + } + }) +} + +func setupProxy() { + proxy := (&httputil.ReverseProxy{ + Transport: &CustomTransport{Transport: http.DefaultTransport}, + Rewrite: func(r *httputil.ProxyRequest) { + p, ok := strings.CutPrefix(r.In.URL.Path, "/proxy/") + if !ok { + log.Panicln(errors.New("proxy received invalid path")) + } + u, err := url.Parse(p) + if err != nil { + log.Panicln(fmt.Errorf("proxy received invalid path: %w", err)) + } + + r.Out.URL = u + r.Out.Host = "" + + xSetHeaders := r.In.Header.Get("X-Set-Headers") + r.Out.Header.Del("X-Set-Headers") + var headers map[string]string + if err := json.Unmarshal([]byte(xSetHeaders), &headers); err == nil { + for k, v := range headers { + if v == "undefined" { + r.Out.Header.Del(k) + } else { + r.Out.Header.Set(k, v) + } + } + } + }, + }) + + http.HandleFunc("/proxy/{url}", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Origin", AllowedOrigin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Set-Headers") + w.Header().Set("Access-Control-Max-Age", "86400") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.WriteHeader(http.StatusNoContent) + return + } + + proxy.ServeHTTP(w, r) + }) +} + +var jar, _ = cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + +type CustomTransport struct { + Transport http.RoundTripper +} + +func (t *CustomTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if jar != nil { + for _, cookie := range jar.Cookies(req.URL) { + req.AddCookie(cookie) + } + } + + resp, err := t.Transport.RoundTrip(req) + if err != nil { + return nil, err + } + + if jar != nil { + if rc := resp.Cookies(); len(rc) > 0 { + jar.SetCookies(req.URL, rc) + } + } + + resp.Header.Set("Access-Control-Allow-Origin", AllowedOrigin) + resp.Header.Set("Access-Control-Allow-Credentials", "true") + + if loc, err := resp.Location(); err == nil { + proxyUrl := "http://" + DaemonAddr + "/proxy/" + resp.Header.Set("Location", proxyUrl+url.PathEscape(loc.String())) + } + + return resp, nil +} diff --git a/cmd/dev.go b/cmd/dev.go new file mode 100644 index 0000000000..3e454c2484 --- /dev/null +++ b/cmd/dev.go @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var devCmd = &cobra.Command{ + Use: "dev", + Short: "Patch Spotify to open in app-developer mode next time it launches", + Run: func(cmd *cobra.Command, args []string) { + if err := execDev(); err != nil { + fmt.Println(err) + return + } + fmt.Println("Mode app-developer enabled for next launch") + + }, +} + +func execDev() error { + offlineBnkPath := filepath.Join(spotifyConfigPath, "offline.bnk") + + file, err := os.OpenFile(offlineBnkPath, os.O_RDWR, 0644) + if err != nil { + return err + } + defer file.Close() + + buf := new(bytes.Buffer) + buf.ReadFrom(file) + content := buf.String() + + if i := strings.Index(content, "app-developer"); i != -1 { + file.WriteAt([]byte("2"), int64(i+14)) + } + if i := strings.LastIndex(content, "app-developer"); i != -1 { + file.WriteAt([]byte("2"), int64(i+15)) + } + + return nil +} + +func init() { + rootCmd.AddCommand(devCmd) +} diff --git a/cmd/fix.go b/cmd/fix.go new file mode 100644 index 0000000000..f70b148e7b --- /dev/null +++ b/cmd/fix.go @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "spicetify/paths" + "strings" + + "github.com/spf13/cobra" +) + +var fixCmd = &cobra.Command{ + Use: "fix", + Short: "Fix your spotify installation", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Restoring Spotify to stock state") + execFix() + }, +} + +func execFix() { + if mirror { + os.RemoveAll(filepath.Join(paths.ConfigPath, "apps")) + } else { + spaBakGlob := filepath.Join(paths.GetSpotifyAppsPath(spotifyDataPath), "*.spa.bak") + spaBaks, err := filepath.Glob(spaBakGlob) + if err != nil { + fmt.Println(err) + } + if len(spaBaks) == 0 { + fmt.Println("Spotify is already in stock state!") + return + } + + for _, spaBak := range spaBaks { + spa := strings.TrimSuffix(spaBak, ".bak") + err = os.RemoveAll(strings.TrimSuffix(spa, ".spa")) + if err != nil { + fmt.Println(err) + } + err = os.Rename(spaBak, spa) + if err != nil { + fmt.Println(err) + } + } + } +} + +func init() { + rootCmd.AddCommand(fixCmd) +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000000..f6f3882b48 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "spicetify/module" + "spicetify/paths" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Perform one-time spicetify initization", + Long: "required to be ran at least once per installation", + Run: func(cmd *cobra.Command, args []string) { + if err := execInit(); err != nil { + fmt.Println(err) + return + } + fmt.Println("Initialized spicetify") + }, +} + +func init() { + rootCmd.AddCommand(initCmd) +} + +func execInit() error { + configFile := viper.GetViper().ConfigFileUsed() + if err := viper.SafeWriteConfigAs(configFile); err != nil { + if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok { + return err + } + } + + folders := []string{"hooks", "modules", "store"} + for _, folder := range folders { + folderPath := filepath.Join(paths.ConfigPath, folder) + os.Remove(folderPath) + } + + return module.SetVault(&module.Vault{Modules: map[module.ModuleIdentifier]module.Module{}}) +} diff --git a/cmd/paths.go b/cmd/paths.go new file mode 100644 index 0000000000..f38fdc07a2 --- /dev/null +++ b/cmd/paths.go @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "fmt" + "spicetify/paths" + + "github.com/spf13/cobra" +) + +var ( + showSpotiyData bool + showSpotifyConfig bool + showConfig bool +) + +var pathsCmd = &cobra.Command{ + Use: "paths", + Short: "Print spicetify config", + Run: func(cmd *cobra.Command, args []string) { + if !showSpotiyData && !showSpotifyConfig && !showConfig { + showSpotiyData = true + showSpotifyConfig = true + showConfig = true + } + fmt.Println("mirror:", mirror) + if showSpotiyData { + fmt.Println("Spotify data:", spotifyDataPath) + } + if showSpotifyConfig { + fmt.Println("Spotify config:", spotifyConfigPath) + } + if showConfig { + fmt.Println("config file:", paths.ConfigPath) + } + }, +} + +func init() { + rootCmd.AddCommand(pathsCmd) + + pathsCmd.Flags().BoolVar(&showSpotiyData, "spotify-data", false, "Show Spotify data path") + pathsCmd.Flags().BoolVar(&showSpotiyData, "spotify-config", false, "Show Spotify config path") + pathsCmd.Flags().BoolVar(&showConfig, "config", false, "Show config path") +} diff --git a/cmd/pkg.go b/cmd/pkg.go new file mode 100644 index 0000000000..bdb22e99ef --- /dev/null +++ b/cmd/pkg.go @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "fmt" + "spicetify/module" + + "github.com/spf13/cobra" +) + +var pkgCmd = &cobra.Command{ + Use: "pkg", + Short: "Manage modules", +} + +var pkgInstallCmd = &cobra.Command{ + Use: "install id url", + Short: "Add and Install module", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + id := args[0] + url := args[1] + identifier := module.NewStoreIdentifier(id) + aurl := module.ArtifactURL(url).Parse().ToUrl() + if err := addAndInstall(aurl, identifier); err != nil { + fmt.Println(err) + } + }, +} + +func addAndInstall(aurl module.ArtifactURL, identifier module.StoreIdentifier) error { + if err := module.AddStoreInVault(identifier, &module.Store{ + Installed: false, + Artifacts: []module.ArtifactURL{aurl}, + }); err != nil { + return err + } + + return module.InstallModule(identifier) +} + +var pkgDeleteCmd = &cobra.Command{ + Use: "delete id", + Short: "Delete and Remove module", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + identifier := module.NewStoreIdentifier(args[0]) + if err := deleteAndRemove(identifier); err != nil { + fmt.Println(err) + } + }, +} + +func deleteAndRemove(identifier module.StoreIdentifier) error { + if err := module.DeleteModule(identifier); err != nil { + return err + } + + return module.RemoveStoreInVault(identifier) +} + +var pkgEnableCmd = &cobra.Command{ + Use: "enable id", + Short: "Enable or Disable module", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + identifier := module.NewStoreIdentifier(args[0]) + if err := module.EnableModuleInVault(identifier); err != nil { + fmt.Println(err) + } + }, +} + +func init() { + rootCmd.AddCommand(pkgCmd) + + pkgCmd.AddCommand(pkgInstallCmd, pkgDeleteCmd, pkgEnableCmd) +} diff --git a/cmd/protocol.go b/cmd/protocol.go new file mode 100644 index 0000000000..3e78744a36 --- /dev/null +++ b/cmd/protocol.go @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "fmt" + "net/url" + "os/exec" + "runtime" + "spicetify/module" + "strings" + + e "spicetify/errors" + + "github.com/spf13/cobra" +) + +var protocolCmd = &cobra.Command{ + Use: "protocol [uri]", + Short: "Internal protocol handler", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + res, err := HandleProtocol(args[0]) + if res != "" { + open("spotify:app:rpc:" + res) + } + if err != nil { + fmt.Println(err) + } + }, +} + +func HandleProtocol(uri string) (string, error) { + u, err := url.Parse(uri) + if err != nil || u.Scheme != "spicetify" { + return "", err + } + uuid, action, _ := strings.Cut(u.Opaque, ":") + response := u.Scheme + ":" + uuid + ":" + arguments := u.Query() + err = hp(action, arguments) + if err == nil { + response += "1" + } else { + response += "0" + } + if uuid == "0" { + response = "" + } + return response, err +} + +func hp(action string, arguments url.Values) error { + switch action { + case "add": + case "fast-install": + case "fast-enable": + _artifacts := arguments["artifacts"] + + identifier := module.NewStoreIdentifier(arguments.Get("id")) + artifacts := make([]module.ArtifactURL, len(_artifacts)) + for i, a := range _artifacts { + artifacts[i] = module.ArtifactURL(a).Parse().ToUrl() + } + checksum := arguments.Get("checksum") + + if err := module.AddStoreInVault(identifier, &module.Store{ + Installed: false, + Artifacts: artifacts, + Checksum: checksum, + }); err != nil { + return err + } + + if action == "add" { + return nil + } + + if err := module.InstallModule(identifier); err != nil { + return err + } + + if action == "fast-install" { + return nil + } + + return module.EnableModuleInVault(identifier) + + case "install": + identifier := module.NewStoreIdentifier(arguments.Get("id")) + return module.InstallModule(identifier) + + case "enable": + identifier := module.NewStoreIdentifier(arguments.Get("id")) + return module.EnableModuleInVault(identifier) + + case "delete": + identifier := module.NewStoreIdentifier(arguments.Get("id")) + return module.DeleteModule(identifier) + + case "remove": + identifier := module.NewStoreIdentifier(arguments.Get("id")) + return module.RemoveStoreInVault(identifier) + + case "fast-delete": + case "fast-remove": + identifier := module.NewStoreIdentifier(arguments.Get("id")) + + if err := module.EnableModuleInVault(module.StoreIdentifier{ + ModuleIdentifier: identifier.ModuleIdentifier, + }); err != nil { + return err + } + + if err := module.DeleteModule(identifier); err != nil { + return err + } + + if action == "fast-delete" { + return nil + } + + return module.RemoveStoreInVault(identifier) + + } + return e.ErrUnsupportedOperation +} + +func init() { + rootCmd.AddCommand(protocolCmd) +} + +func open(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start"} + case "darwin": + cmd = "open" + default: + cmd = "xdg-open" + } + args = append(args, url) + return exec.Command(cmd, args...).Start() +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000000..d936e71a4f --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "spicetify/paths" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + // autoUpdate bool + + mirror bool + spotifyDataPath string + spotifyExecPath string + spotifyConfigPath string + cfgFile string +) + +var rootCmd = &cobra.Command{ + Use: "spicetify", + Short: "Make Spotify your own", + Long: `Bespoke is a CLI utility that empowers the desktop Spotify client with custom themes and extensions`, + Run: func(cmd *cobra.Command, args []string) {}, +} + +func Execute() { + cobra.MousetrapHelpText = "" + err := rootCmd.Execute() + if err != nil { + panic(err) + } +} + +func init() { + cobra.OnInitialize(initConfig) + + rootCmd.PersistentFlags().BoolVarP(&mirror, "mirror", "m", false, "Mirror Spotify files instead of patching them directly") + rootCmd.PersistentFlags().StringVar(&spotifyDataPath, "spotify-data-path", paths.GetDefaultSpotifyDataPath(), "Override Spotify data folder") + rootCmd.PersistentFlags().StringVar(&spotifyExecPath, "spotify-exec-path", paths.GetDefaultSpotifyExecPath(spotifyDataPath), "Override Spotify executable path") + rootCmd.PersistentFlags().StringVar(&spotifyConfigPath, "spotify-config-path", paths.GetDefaultSpotifyConfigPath(), "Override Spotify config folder (containing prefs & offline.bnk)") + + viper.BindPFlag("mirror", rootCmd.PersistentFlags().Lookup("mirror")) + viper.BindPFlag("spotify-data", rootCmd.PersistentFlags().Lookup("spotify-data")) + viper.BindPFlag("spotify-config", rootCmd.PersistentFlags().Lookup("spotify-config")) + + defaultcfgFile := filepath.Join(paths.ConfigPath, "config.yaml") + + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", defaultcfgFile, "config file") +} + +func initConfig() { + viper.SetConfigFile(cfgFile) + viper.AutomaticEnv() + + viper.SetDefault("mirror", mirror) + viper.SetDefault("spotify-data", spotifyDataPath) + viper.SetDefault("spotify-config", spotifyConfigPath) + + if err := viper.ReadInConfig(); err == nil { + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) + + mirror = viper.GetBool("mirror") + spotifyDataPath = viper.GetString("spotify-data") + spotifyConfigPath = viper.GetString("spotify-config") + } +} diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 0000000000..5545b6eb6a --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "os/exec" + "path/filepath" + + "spicetify/paths" + + "github.com/spf13/cobra" +) + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Launch Spotify with your favorite addons", + Run: func(cmd *cobra.Command, args []string) { + execRun(args) + }, +} + +func prepend[Type any](slice []Type, elems ...Type) []Type { + return append(elems, slice...) +} + +func execRun(args []string) { + defaultArgs := []string{ /*"--disable-web-security",*/ } + args = prepend(args, defaultArgs...) + if mirror { + args = prepend(args, "--app-directory="+filepath.Join(paths.ConfigPath, "apps")) + } + exec.Command(spotifyExecPath, args...).Start() +} + +func init() { + rootCmd.AddCommand(runCmd) +} diff --git a/cmd/sync.go b/cmd/sync.go new file mode 100644 index 0000000000..fefef50cd7 --- /dev/null +++ b/cmd/sync.go @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "fmt" + "net/http" + "path/filepath" + "spicetify/archive" + "spicetify/paths" + + "github.com/spf13/cobra" +) + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Update spicetify hooks from GitHub", + Run: func(cmd *cobra.Command, args []string) { + if err := installHooks(); err != nil { + fmt.Println(err) + return + } + fmt.Println("Hooks updated successfully") + }, +} + +// TODO: let the user choose which release to install (& include version compatibility info) +func installHooks() error { + res, err := http.Get("http://github.com/spicetify/hooks/releases/latest/download/hooks.tar.gz") + if err != nil { + return err + } + defer res.Body.Close() + + return archive.UnTarGZ(res.Body, filepath.Join(paths.ConfigPath, "hooks")) +} + +func init() { + rootCmd.AddCommand(syncCmd) +} diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000000..8d6ba4f6ac --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package cmd + +import ( + "bytes" + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update on|off", + Short: "Patch Spotify to block/unblock updates", + Args: cobra.ExactArgs(1), + ValidArgs: []string{"on", "off"}, + Run: func(cmd *cobra.Command, args []string) { + b := args[0] == "on" + if err := toggleUpdates(b); err != nil { + fmt.Println(err) + return + } + fmt.Println("Patched the executable successfully") + }, +} + +func toggleUpdates(b bool) error { + file, err := os.OpenFile(spotifyExecPath, os.O_RDWR, 0644) + if err != nil { + return err + } + defer file.Close() + + buf := new(bytes.Buffer) + buf.ReadFrom(file) + content := buf.String() + + i := strings.Index(content, "desktop-update/") + if i == -1 { + return errors.New("can't find update endpoint in executable") + } + var s string + if b { + s = "v2/update" + } else { + s = "no/thanks" + } + file.WriteAt([]byte(s), int64(i+15)) + return nil +} + +func init() { + rootCmd.AddCommand(updateCmd) +} diff --git a/css-map.json b/css-map.json deleted file mode 100644 index ec91651bee..0000000000 --- a/css-map.json +++ /dev/null @@ -1,1364 +0,0 @@ -{ - "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", - "CjnwbSTpODW56Gerg7X6": "artist-artistAbout-bio", - "Fzu8dfizHyYedxyDjaQ2": "artist-artistAbout-bioContainer", - "Q_OUHp7iDNLBcO2ZYI2x": "artist-artistAbout-cityBlock", - "MHIOvvlSYRmF7VAJDLWy": "artist-artistAbout-close", - "ml8qANY77Ah3fx5VTjoe": "artist-artistAbout-columnContainer", - "uhDzVbFHyCQDH6WrWZaC": "artist-artistAbout-container", - "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", - "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", - "mM_me6VjRfHLPkqAMqEI": "artist-artistAbout-postedByAvatar", - "tQp8UOu8jGduQXUTcv0c": "artist-artistAbout-rank", - "pHrwZOFBdT8FNXnmcPPI": "artist-artistAbout-scrollbars", - "y72RJ2LhCkAyAfZ8tzUt": "artist-artistAbout-sideBlock", - "upZIQtiBy1Tr0ZbvhnSL": "artist-artistAbout-stats", - "ndIZG_atdpv_tBZtqQhk": "artist-artistAbout-statsContainer", - "GB_nwkJosBf_NLdZAgAx": "artist-artistDiscography-active", - "cFKW94askf6sbyOYlRqe": "artist-artistDiscography-artistName", - "Ihlsi5QU3AnIfSgnhorS": "artist-artistDiscography-button", - "h8z21OhjsxoY1IvpR1QP": "artist-artistDiscography-cardGrid", - "Rz6JI4bCxJVeSWe6LtEX": "artist-artistDiscography-firstAlbum", - "O61y0bIacmTFxhhbL1Bl": "artist-artistDiscography-headerButtons", - "fEvxx8vl3zTNWsuC8lpx": "artist-artistDiscography-headerContainer", - "n1EzbHQahSKztskTUAm3": "artist-artistDiscography-headerImage", - "hyHkMMynp3uUsmEtOkSN": "artist-artistDiscography-headerMetadata", - "kRb29P1Fo5blFMPRDSdq": "artist-artistDiscography-headerTitle", - "P7nfAtcLXgPiUCJrlUjA": "artist-artistDiscography-topBar", - "EvQHNTBhaU3rGCRRlAWj": "artist-artistDiscography-topBar", - "QplCuuGSoV4updqTSLq9": "artist-artistDiscography-topBarScrolled", - "oxpxKtAxG4E2DCK0AJo9": "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", - "H0HbpIM3UrcupWIAjLWu": "cover-art", - "W0TACB7OY0iXtKVOtEhY": "cover-art-auto-height", - "zmOtW0vqqn1qpZrtQ_w9": "cover-art-icon", - "FqmFsMhuF4D0s35Z62Js": "cover-art-image", - "GSFvITwD84dS2JA62Mtj": "desktopmodals-aboutSpotifyModal-closeButton", - "UnLGG6p932k7WyjkB9Vo": "desktopmodals-aboutSpotifyModal-container", - "KlzblASEYfUfaykBFZgM": "desktopmodals-aboutSpotifyModal-content", - "XF1XXenkrbdAK2rRoxoU": "desktopmodals-aboutSpotifyModal-copyright", - "Ifnz1lh1jjvqPqJ4KPo8": "desktopmodals-aboutSpotifyModal-licensing", - "qsKpcFrhrA8KtuTVIN_y": "desktopmodals-licensesModal-buttonContainer", - "uYKs_kQMPOziaeDj877B": "desktopmodals-licensesModal-container", - "i8qeSJJVx4PXb7fsvOTd": "desktopmodals-licensesModal-content", - "WhIzm3S3R6Ker3XvpYW6": "desktopmodals-licensesModal-licensesFrame", - "qi0hX8uXrbQyS6tvdDBt": "desktopmodals-versionStatus-container", - "WomzHWnDO_yFyjnkd49P": "desktopmodals-versionStatus-content", - "R83hOohwVshnd6bEkDO4": "desktopmodals-versionStatus-copyButton", - "YJMECPbMHWgMUs8RFdcV": "folder-folderPage-sectionWrapper", - "cUwQnQoE3OqXqSYLT0hv": "link-subtle", - "VUXMMFKWudUWE1kIXZoS": "link-subtle", - "iKgf4UDhbRTHxmZSuAEc": "lyrics-lyrics-adLeaderboardIsEnabled", - "L9xhJOJnV2OL5Chm3Jew": "lyrics-lyrics-background", - "tr8V5eHsUaIkOYVw7eSG": "lyrics-lyrics-container", - "Q2RPoHcoxygOoPLXLMww": "lyrics-lyrics-contentContainer", - "esRByMgBY3TiENAsbDHA": "lyrics-lyrics-contentWrapper", - "iPBJpp5EVkRE24N6vmGA": "lyrics-lyrics-coverTopBar", - "SaEkeiyzAoXnWVSDiTR7": "lyrics-lyrics-vocalRemoval", - "arY01KDGhWNgzlAHlhpd": "lyrics-lyricsContent-active", - "iq4cgi0YEKr6DGaTtzUj": "lyrics-lyricsContent-description", - "MEjuIn9iTBQbnCqHpkoQ": "lyrics-lyricsContent-highlight", - "_LKG3z7SnerR0eigPCoK": "lyrics-lyricsContent-isInteractive", - "NiCdLCpp3o2z6nBrayOn": "lyrics-lyricsContent-lyric", - "kGR_hu4tdj9PnUlSPaRL": "lyrics-lyricsContent-provider", - "A3ohAQNHsDIMv2EM3Ytp": "lyrics-lyricsContent-text", - "HxblHEsl2WX2yhubfVIc": "lyrics-lyricsContent-unsynced", - "E4q8ogfdWtye7YgotBlN": "main-actionBar-ActionBar", - "eSg4ntPU2KQLfpLGXAww": "main-actionBar-ActionBarRow", - "CoLO4pdSl8LGWyVZA00t": "main-actionBarBackground-background", - "GTAFfOA_w5vh_bDaGJAG": "main-actionButtons", - "NPvLJSRWfv1Joo8dF0D8": "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", - "Xz3tlahv16UpqKBW5HdK": "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", - "aAYpzGljXQv1_zfopxaH": "main-card-cardContainer", - "tsv7E_RBBw6v0XTQlcRo": "main-card-cardLink", - "E1N1ByPFWo4AJLHovIBQ": "main-card-cardMetadata", - "xHz124sSHSCYHecLCTfi": "main-card-cardTitle", - "w9Tpa4Y111UM5u1WMkEl": "main-card-DownloadStatusIndicator", - "XiVwj5uoqqSFpS4cYOC6": "main-card-draggable", - "kpO5z7v_Nr22lge440TY": "main-card-hero", - "xBV4XgMq0gC5lQICFWY_": "main-card-imageContainer", - "NH6UaoYYe47eIHA2Rmal": "main-card-imageContainerOld", - "AYwATC_zEPwCkmO1yc8R": "main-card-imageContainerSkeleton", - "UB3cP9wsqoAMAHaeBGDt": "main-card-imagePlaceholder", - "r9Oj1LabijReMkOyeUxw": "main-card-newEpisodeIndicator", - "woJQ5t2YiaJhjTv_KE7p": "main-card-PlayButtonContainer", - "zuKTW9yEI9rToECOcWG3": "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", - "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", - "X05XDhpQJ7THPHfgbUk1": "main-confirmDialog-buttonContainer", - "RVgHI2ejYct8LjT1AO7m": "main-confirmDialog-container", - "m0yIuS1Q6XRA5R4PNEhl": "main-confirmDialog-overlay", - "gQoa8JTSpjSmYyABcag2": "main-connectBar-connectBar", - "T3hkVxXuSbCYOD2GIeQd": "main-connectBar-connected", - "GcHojieewpdN1c8vbtwk": "main-connectBar-connecting", - "Cm3jbLBimhqdYEcNTVPj": "main-contextMenu-addToPlaylistSubtitle", - "OVv1uDz67TLurN8o6LtQ": "main-contextMenu-addToPlaylistTitle", - "EyDGMdJOp8ktTzmRFcQM": "main-contextMenu-clearButton", - "pzkhLqffqF_4hucrVVQA": "main-contextMenu-disabled", - "jJzb9peSGYsUDKbc5QBy": "main-contextMenu-disabled", - "NmbeQabkSLXf0mTAhSLl": "main-contextMenu-dividerAfter", - "pXsnH0sMlWUGBffe24cr": "main-contextMenu-dividerAfter", - "QgtQw2NJz7giDZxap2BB": "main-contextMenu-dividerBefore", - "hYwu7ZpcbQIP6iM5fzAu": "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", - "re2d5HDzt6T4XBgqcBhi": "main-contextMenu-menuHeading", - "qUeWph4VP9DwR4xOfabh": "main-contextMenu-menuHeading", - "DuEPSADpSwCcO880xjUG": "main-contextMenu-menuItem", - "GqvHlGax1jo6vO9D0wHH": "main-contextMenu-menuItem", - "rQ6LXqVlEOGZdGIG0LgP": "main-contextMenu-menuItem", - "wC9sIed7pfp47wZbmU6m": "main-contextMenu-menuItemButton", - "SN9k988q2Seb_joCaEny": "main-contextMenu-menuItemButton", - "mWj8N7D_OlsbDgtQx5GW": "main-contextMenu-menuItemButton", - "PDPsYDh4ntfQE3B4duUI": "main-contextMenu-menuItemLabel", - "ctAknuakI8idqf_S9tvT": "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", - "abJqsjKClbniwiTCZ7bC": "main-contextMenu-subMenuLeading", - "wtEUrk4Sxa5e3QZhvbrs": "main-contextMenu-subMenuLeading", - "X8yW2lJbFCQfV5GjoRwL": "main-contextMenu-tippy", - "nYdM55iHFByRTzJUmx9X": "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", - "qWcH8e2laY9sYOuCsOAx": "main-coverSlotCollapsed-expandButton", - "_9sCL61nGvQFXv2u02jXw": "main-coverSlotCollapsed-expandButton", - "IcyWfMS5VkeOhaI7OWIx": "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", - "jmu6DFPvhxRl0wSfmv2O": "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", - "jN7ZUHc7IxpwvWsjb4jo": "main-editImageButton-copy", - "w3w0DS8atwcgOQJAKAV2": "main-editImageButton-icon", - "xfQXUkj6ThzTYbfF8ilt": "main-editImageButton-image", - "Usk1cPR7qbn7RudRN3td": "main-editImageButton-overlay", - "CdHBSRh3RhPwBNIBQtkD": "main-editImageButton-rounded", - "PvesGiAqdV7dazP6Qulv": "main-embedWidgetGenerator-active", - "oBoIIlKrwQjxXpvOiOa0": "main-embedWidgetGenerator-closeBtn", - "U_nX_1rJoqQSWPJRE6zb": "main-embedWidgetGenerator-code", - "uUYNnjSt8m3EqVjsnHgh": "main-embedWidgetGenerator-container", - "IJHNf0vxPSbPE1egoG4N": "main-embedWidgetGenerator-content", - "dbKIqeJaR5jYCnOeeDJt": "main-embedWidgetGenerator-contentCode", - "VaChXV5vRmHB24UihCAG": "main-embedWidgetGenerator-contentFooter", - "zwGMyD7hN0H1Xx8hV5MH": "main-embedWidgetGenerator-contentHeader", - "NYCZnquuE8_PvM2bxZ3r": "main-embedWidgetGenerator-contentIframe", - "gIsqpLXuGzGw3zRTolrW": "main-embedWidgetGenerator-copyBtn", - "MnqXew54JWlqw2TG7vDK": "main-embedWidgetGenerator-copyWrapper", - "LeDataq3AAGozahFmBJL": "main-embedWidgetGenerator-crossSep", - "XPFcOWyZPRej4gH_M5XQ": "main-embedWidgetGenerator-darkControl", - "tWZ0D98BTxSAFIgp4_tP": "main-embedWidgetGenerator-dimensionField", - "TSQvyxomq15adJzfCj3j": "main-embedWidgetGenerator-dimensionLabel", - "dNNjNzGLV8tjhIuats54": "main-embedWidgetGenerator-dimensionsContainer", - "bOIRpQiHUAEfp8ntStTo": "main-embedWidgetGenerator-header", - "UrcPMgHLfxfJesFPrNvJ": "main-embedWidgetGenerator-loadingIndicator", - "nT444OUxO6Vammm8GUVl": "main-embedWidgetGenerator-startAt", - "wVDmvGlxMo4VisD89XUI": "main-embedWidgetGenerator-terms", - "mKjr_3b27jFzEoJBJYQw": "main-embedWidgetGenerator-theme", - "gAlrxXSfi5DpMcVMiUsz": "main-embedWidgetGenerator-themeDescription", - "mBGUavHPMlD5mfAmiy5g": "main-embedWidgetGenerator-themeRadio", - "bps2gHcEE9dOV6djVLWF": "main-embedWidgetGenerator-timestampInput", - "h7qbIvm2yIiZVYqONyFG": "main-embedWidgetGenerator-tooltip", - "xwNumE3szip6EhU74j3f": "main-embedWidgetGenerator-tooltipInitiator", - "ObwDQms6Cu8h_8FMWzAT": "main-embedWidgetGenerator-transControl", - "gB6AcMixPEmdr96SUSBM": "main-embedWidgetGenerator-visible", - "P4iG24p5ttZDxkQJsiDb": "main-embedWidgetGenerator-widthField", - "MyW8tKEekj9lKQsviDdP": "main-entityHeader-background", - "gHImFiUWOg93pvTefeAD": "main-entityHeader-backgroundColor", - "H0vWBc23fJOetym6NudG": "main-entityHeader-bold", - "ta4ePOlmGXjBYPTd90lh": "main-entityHeader-circle", - "NXiYChVp4Oydfxd7rT5r": "main-entityHeader-container", - "kuSGKO1BuKZ3fgas7_T7": "main-entityHeader-creatorButton", - "NO_VO3MRVl9z3z56d8Lg": "main-entityHeader-creatorWrapper", - "Ydwa1P5GkCggtLlSvphs": "main-entityHeader-detailsText", - "n4hTP7ZeAOT_UQEkRUR7": "main-entityHeader-divider", - "k2I8B0MzXkAJ6_s8okM7": "main-entityHeader-gradient", - "PUIUCdIR_h05BC2EDgIP": "main-entityHeader-gray", - "RP2rRchy4i8TIp1CTmb7": "main-entityHeader-headerText", - "CmkY1Ag0tJDfnFXbGgju": "main-entityHeader-image", - "_gLjHpwOxHFwo5nLM8hb": "main-entityHeader-imageContainer", - "GoU8CT9Vm_TP_LyYJTsf": "main-entityHeader-imageContainerClickable", - "_osiFNXU9Cy1X0CYaU9Z": "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", - "Yc6ftz7mCqbsTN4ha_ke": "main-entityHeader-metaDataAuthor", - "RANLXG3qKB61Bh33I0r2": "main-entityHeader-metaDataText", - "lD5bMttyrRNzsKch6ysa": "main-entityHeader-newEntityHeaders", - "ByDYHUfzUKVgFwT5bTxp": "main-entityHeader-newEntriesIndicator", - "RMDSGDMFrx8eXHpFphqG": "main-entityHeader-nonWrapped", - "xYgjMpAjE5XT05aRIezb": "main-entityHeader-overlay", - "YW4dYEf5ZuLzMfSjsqZk": "main-entityHeader-piled", - "lp9Tfm4rsM9_pfbIE0zd": "main-entityHeader-pretitle", - "YbDIZ84mS7tzHr1tgWE9": "main-entityHeader-roundedCorners", - "_EShSNaBK1wUIaZQFJJQ": "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", - "wCkmVGEQh3je1hrbsFBY": "main-entityHeader-titleButton", - "o4KVKZmeHsoRZ2Ltl078": "main-entityHeader-titleInner", - "HcA9WjbLc4x02X8Ty0uO": "main-entityHeader-topbarContent", - "lro6AjUrZFH6zxjmOGg0": "main-entityHeader-topbarContentFadeIn", - "G7zO58ORUHxcUw0sXktM": "main-entityHeader-topbarTitle", - "E0MlERsQ_zvY3BX7ZzPp": "main-entityHeader-uppercase", - "L29j6que7xOiNSH_EOvQ": "main-entityHeader-wavesBackground", - "XPjEhsPyuOvMZ9NsDrxT": "main-entityHeader-withBackgroundImage", - "KAZD28usA1vPz5GVpm63": "main-genericButton-button", - "RK45o6dbvO1mb0wQtSwq": "main-genericButton-buttonActive", - "EHxL6K_6WWDlTCZP6x5w": "main-genericButton-buttonActiveDot", - "OomFKn3bsxs5JfNUoWhz": "main-globalNav-buddyFeed", - "VizXsWMIuNfKGN5pMyox": "main-globalNav-historyButtons", - "Z6t_8rA6LOBrX3huqRJG": "main-globalNav-historyButtonsContainer", - "pIM9jg__39NIpOvXG89b": "main-globalNav-historyButtonsContainer", - "rBX1EWVZ2EaPwP4y1Gkd": "main-globalNav-icon", - "jdlOKroADlFeZZQeTdp8": "main-globalNav-link-icon", - "dIfr5oVr5kotAi0HsIsW": "main-globalNav-link-icon", - "bWBqSiXEceAj1SnzqusU": "main-globalNav-navLink", - "voA9ZoTTlPFyLpckNw3S": "main-globalNav-navLinkActive", - "ETjtwGvAB4lRVqSzm8nA": "main-globalNav-navLinkActive", - "QrpHSphgBSqzODEHqr_t": "main-globalNav-searchContainer", - "lj0eGI6WEtfxFX7irC03": "main-globalNav-searchContainer", - "fksI89zEXwqKWm1O6sJm": "main-globalNav-searchInputContainer", - "W9VXOkC43GP_7ULClgxQ": "main-globalNav-searchInputContainer", - "PvsV2JgJRDME1vDn6IJL": "main-globalNav-searchInputSection", - "tDFP1X98EgqQIPaumxRt": "main-globalNav-searchInputSection", - "W02pQCvfy5Bin7z4EAzo": "main-globalNav-searchSection", - "gj5VcIUC9oD2p4BsxzGE": "main-globalNav-searchSection", - "soGhxDX6VjS7dBxX9Hbd": "main-gridContainer-fixedWidth", - "iKwGKEfAfW7Rkx2_Ba4E": "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", - "zbU90jX5VWUhVlpUda7B": "main-home-filterChipsContainer", - "rX_OmqCngvY5ZCoYBZgb": "main-home-filterChipsSection", - "uIJTvxFOg2izOY7aRRiU": "main-home-home", - "HsbczDqu9qjcYr7EIdHR": "main-home-homeHeader", - "S4OmZ_IZexmZ5dasPqW5": "main-home-homeHeader", - "DQRst2WAECYq2xsrsPRA": "main-home-isOffline", - "QbD9zl7z3AhEQu_TGmo8": "main-home-subfeedSection", - "_kVOt2H6WxXzURx2FRLM": "main-home-withAds", - "mMx2LUixlnN_Fu45JpFB": "main-image-image", - "Yn2Ei5QZn19gria6LjZj": "main-image-loaded", - "yOKoknIYYzAE90pe7_SE": "main-image-loading", - "wcftliF4QjZKB1CYgEON": "main-imagePicker-fileInput", - "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", - "K7fGF95OD9aI3zdYnFXg": "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", - "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", - "P4eSEARM2h24PZxMHz1T": "main-nowPlayingBar-center", - "sVv2OQORCQ4kf6iKfUTF": "main-nowPlayingBar-center", - "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", - "tT6x7wFZmjldiCeh6HzO": "main-nowPlayingBar-isAnonymous", - "OgkbKIVYE_mrNpYESylB": "main-nowPlayingBar-left", - "snFK6_ei0caqvFI6As9Q": "main-nowPlayingBar-left", - "Xmv2oAnTB85QE4sqbK00": "main-nowPlayingBar-lyricsButton", - "OCY4jHBlCVZEyGvtSv0J": "main-nowPlayingBar-nowPlayingBar", - "udArIAqnfUQPQew2VAns": "main-nowPlayingBar-nowPlayingBar", - "jOKLc29vP0Bz1K0TsDtX": "main-nowPlayingBar-right", - "pLifNBuHRY8cZkZyEqwL": "main-nowPlayingBar-right", - "uVRRxsH6RKj3Dzhl40Ok": "main-nowPlayingBar-topButton", - "ExuDUBJ7bk8vT6INnm9F": "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", - "j9I5h3Z4o0fKNgI1fIjb": "main-nowPlayingView-coverArt", - "zL6hQR4mukVUUQaa_7K1": "main-nowPlayingView-coverArtContainer", - "l2PpoXJouAgqFCuNT3iB": "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", - "fIpDXK7M3W0Bn3FgLSRe": "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", - "Mj718TwbPAUi_iNAcsmz": "main-nowPlayingView-queueItem", - "Nyxk_izrYbGecpgmtp91": "main-nowPlayingView-queueItemEntityImage", - "Ai_McRq9wJEYK21w8nX_": "main-nowPlayingView-section", - "EVqc6HChiM9pEqBYAiUE": "main-nowPlayingView-sectionHeader", - "gpDSOimnzH4zTJmE7UR5": "main-nowPlayingView-sectionHeaderSpacing", - "zZdI03asKaUCNlbhjDAv": "main-nowPlayingView-sectionHeaderText", - "hfdkySA4kiUldFsPj9lD": "main-nowPlayingView-trackInfo", - "BFR9Zt3zpL8BATBMiwQB": "main-nowPlayingWidget-coverArt", - "bYHWD_eQ1jAh3sAKTHtr": "main-nowPlayingWidget-coverExpanded", - "deomraqfhIAoSB3SgXpu": "main-nowPlayingWidget-nowPlaying", - "j96cpCtZAIdqxcDrYHPI": "main-nowPlayingWidget-trackInfo", - "fFv7yCuLuIO1dAGZHcVf": "main-pageErrorTemplate-errorBody", - "npFSJSO1wsu3mEEGb5bh": "main-playbackBarRemainingTime-container", - "kQqIrFPM5PjMWb5qUS56": "main-playbackBarRemainingTime-container", - "f4XfIkH9v3tBTnI8AEDj": "main-playButton-lockIcon", - "IeLnf2wUHVKqxhzBcBoM": "main-playButton-PlayButton", - "PFgcCoJSWC3KjhZxHDYH": "main-playButton-PlayButton", - "KOoUMuC7IxI_1Pi4r4m5": "main-playButton-primary", - "VgweZbpfbMSfOcTeGNj_": "main-playButton-secondary", - "D7HQ50jRBGU8qaAGHRTw": "main-playButton-transparent", - "ke5Pf1zkbk0eGnRWZYyg": "main-playlistEditDetailsModal-albumCover", - "CMQlNrl4E4TNHVowBoZ9": "main-playlistEditDetailsModal-characterCounter", - "MQQEonum615k8mGkliT_": "main-playlistEditDetailsModal-closeBtn", - "PiyAiXdQULEnWAHP0tu1": "main-playlistEditDetailsModal-container", - "CU0wnmWejIvyEsRRtSac": "main-playlistEditDetailsModal-content", - "_qRr3jb13hOiy1EvSL_r": "main-playlistEditDetailsModal-description", - "lXzpMHpJSt7uRB1DXwen": "main-playlistEditDetailsModal-descriptionCharacterCounter", - "c0CddR8wF7kDxvU6uM8B": "main-playlistEditDetailsModal-descriptionTextarea", - "B_4ndHDFJnCj4dxQmvKA": "main-playlistEditDetailsModal-descriptionTextareaWithPadding", - "g4NoSm4nlTuArQvfAaDa": "main-playlistEditDetailsModal-disclaimer", - "R2w_sH83CJU9Yhnu0xyt": "main-playlistEditDetailsModal-header", - "UxrKbK6rUr4vmo6SmCab": "main-playlistEditDetailsModal-imageChangeButton", - "mtGn3ylACuEcnBDCuR2g": "main-playlistEditDetailsModal-imageDropDownButton", - "i2x0uFyIEg3YN6njOngZ": "main-playlistEditDetailsModal-imageDropDownContainer", - "St_O5qpuAv8_Rt8xLueG": "main-playlistEditDetailsModal-imageLoadingContainer", - "_rBnbQXOUaS8hxF05Vvj": "main-playlistEditDetailsModal-insertLinkButton", - "Up_Ke_BKTraatSMY_Po_": "main-playlistEditDetailsModal-save", - "KHbA1pftwmeRxPgRj6XW": "main-playlistEditDetailsModal-sectionsContainer", - "f0GjZQZc4c_bKpqdyKbq": "main-playlistEditDetailsModal-textElement", - "UCj7uEr7vR_0DO3cQHcX": "main-playlistEditDetailsModal-textElementError", - "JqI3LI5Df0M4YNOvUSCw": "main-playlistEditDetailsModal-textElementInfo", - "rosHlzYfiO0UfpmOhP4I": "main-playlistEditDetailsModal-textElementLabel", - "RLzMolC7kIdp65LyfQPb": "main-playlistEditDetailsModal-title", - "qOBo9jzgJoMkt2Ad8fur": "main-playlistEditDetailsModal-titleCharacterCounter", - "JaGLdeBa2UaUMBT44vqI": "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", - "JUa6JJNj7R_Y3i4P8YUX": "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", - "Z4InHgCs2uhk0MU93y_a": "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", - "IAyWaeDamLJLjxuPeVKw": "main-topBar-buddyFeed", - "WtC1lGbmQRplD6JBhNFU": "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", - "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", - "CWFTCu03cdDALodezHmA": "main-topBar-responsiveForward", - "fl1Ov5aB9YCKnMkJYpEu": "main-topBar-right", - "VGKBvschhennrwdTibUE": "main-topBar-rightSidebarVisible", - "g3Xinb8x23n81ejvS9Uj": "main-topBar-searchBar", - "CVuGEUIxLkNKpMds8AFS": "main-topBar-searchBar", - "btOYheZlYlaVEyO9iEBk": "main-topBar-sectionWrapper", - "sibxBMlr_oxWTfBrEz2G": "main-topBar-signupButton", - "eBrbJuUWgMoCkOgWs5uw": "main-topBar-topBarContainer", - "qHWqOt_TYlFxiF0Dm2fD": "main-topBar-topbarContent", - "rwdnt1SmeRC_lhLVfIzg": "main-topBar-topbarContentRight", - "rovbQsmAS_mwvpKHaVhQ": "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", - "xd9f6OsPqmyb6CefhTRR": "main-trackCreditsModal-clickableCreditsEntry", - "VKCcyYujazVPj6VkksPM": "main-trackCreditsModal-closeBtn", - "uV8q95GGAb2VDtL3gpYa": "main-trackCreditsModal-container", - "IpshWHA6nc9nJxRssAlb": "main-trackCreditsModal-creditsEntry", - "beyOcd3p0PEzhrlKIbU1": "main-trackCreditsModal-creditsGroup", - "pGU_qEtNT1qWKjrRbvan": "main-trackCreditsModal-header", - "Nw1INlIyra3LT1JjvoqH": "main-trackCreditsModal-mainSection", - "iGT1RlMPCwUlIiRPbOqg": "main-trackCreditsModal-originalCredits", - "bpaf11XBuMN_hd95L9ol": "main-trackCreditsModal-sectionTitle", - "DAIH23Yj6NeioZ6jooQ6": "main-trackCreditsModal-sourceNames", - "gpNta6i8q3KYJC6WBZQC": "main-trackInfo-artists", - "w_TTPh4y9H1YD6UrTMHa": "main-trackInfo-artists", - "ZcNcu7WZgOAz_Mkcoff3": "main-trackInfo-container", - "iZrIHsls0lCEhoMDA9kc": "main-trackInfo-container", - "W5cB_o0XkkU7Q8tlTGxq": "main-trackInfo-contentContainer", - "PGSe59fD1Hwc9yUM2d3U": "main-trackInfo-contentWrapper", - "s1jyNJBxq16eqkqCf6Ax": "main-trackInfo-enhanced", - "p2ya1fQ3o9pY4alcW0o4": "main-trackInfo-enhanced", - "y6bXimbi8JAcblOFxTap": "main-trackInfo-equalGradientWidth", - "Q_174taY6n64ZGC3GsKj": "main-trackInfo-name", - "PcH6VnzkkDqD36P93i9Q": "main-trackInfo-name", - "eSMjmiD29Ox35O95waw6": "main-trackInfo-overlay", - "aMhBfOqJxn4jKzFN07Lg": "main-trackInfo-rightToLeft", - "Ty1q4GZz5EJ7Yl2jHs5I": "main-trackInfo-xsmallBadges", - "MrkH0O1OzmNv_oCQdvI8": "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", - "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", - "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", - "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", - "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", - "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", - "EfStVlHpnUDOJF3pM93I": "main-trackList-rowImageFallback", - "tgCyNnKttOMQXfuqVuhI": "main-trackList-rowImageFallback", - "RfidWIoz8FON2WhFoItU": "main-trackList-rowImagePlayButton", - "j2s64Lz8y6VzBLB_V9Gm": "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", - "gQnx5tArze5q1wfG6AWJ": "main-trackList-rowMainContentCompact", - "ft6dUifK4i03829TBAqC": "main-trackList-rowMainContentCompact", - "VpYFchIiPg3tPhBGyynT": "main-trackList-rowMarker", - "ucB9avGYvzsmzXUOw0S7": "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", - "NZAU7CsuZsMeMQB8zYUu": "main-trackList-rowSectionIndex", - "fS0C4IgbHviZxIVGC736": "main-trackList-rowSectionIndex", - "gvLrgQXBFVW6m9MscfFA": "main-trackList-rowSectionStart", - "w46g_LQVSLE9xK399VYf": "main-trackList-rowSectionStart", - "bfQ2S9bMXr_kJjqEfcwA": "main-trackList-rowSectionVariable", - "_TH6YAXEzJtzSxhkGSqu": "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", - "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", - "ixZyJJ3SHxsSb3NHkhWn": "main-trackList-trackListCompactMode", - "iHiqmESWdGRBwk5cS7ZZ": "main-trackList-trackListCompactMode", - "koyeY6AgGRPmyPITi7yO": "main-trackList-trackListHeader", - "IpXjqI9ouS_N5zi0WM88": "main-trackList-trackListHeader", - "dZPmmYYhskhqHJCAruvI": "main-trackList-trackListHeaderRow", - "ePPpO_NuGDUxVRTw7y6W": "main-trackList-trackListHeaderRow", - "qJOhHoRcFhHJpEQ2CwFT": "main-trackList-trackListHeaderStuck", - "h4HgbO_Uu1JYg5UGANeQ": "main-trackList-trackListRow", - "IjYxRc5luMiDPhKhZVUH": "main-trackList-trackListRow", - "wTUruPetkKdWAR1dd6w4": "main-trackList-trackListRowGrid", - "UpiE7J6vPrJIa59qxts4": "main-trackList-trackListRowGrid", - "vOp2HlcPkxOHebo3If32": "main-useDropTarget-album", - "O0AN8Ty_Cxd4iLwyKATB": "main-useDropTarget-album", - "VNdHKKznHkpJ0VHoDmai": "main-useDropTarget-artist", - "wQnUXn1m6Gy4PH8jhslb": "main-useDropTarget-artist", - "zWWLnqWslTLHwq3wBgGB": "main-useDropTarget-audiobook", - "ufICQKJq0XJE5iiIsZfj": "main-useDropTarget-base", - "NxEINIJHGytq4gF1r2N1": "main-useDropTarget-base", - "ETclQEbcAcQdGdSioHaJ": "main-useDropTarget-episode", - "XNjgtSbyhshr7YQcVvry": "main-useDropTarget-episode", - "cuH8l_VHkTiz_NYVslQe": "main-useDropTarget-folder", - "mhuhir0ikRqXAPHU8ZZ1": "main-useDropTarget-folder", - "aRyoyQFJkzhoSOnf2ERM": "main-useDropTarget-local", - "odS2IW9wfNVHhkhc0l_X": "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", - "atsRVFhRDxRbOyXyFZjS": "main-userWidget-active", - "odcjv30UQnjaTv4sylc0": "main-userWidget-box", - "KAq2kDjXj2VS4eXrFL4i": "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", - "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", - "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", - "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", - "wBsWS202aGdsul2kEGUf": "main-yourLibraryX-filterArea", - "paiZmlAHHhmZonuGJRAr": "main-yourLibraryX-filterArea", - "MLbFLVC33caOj3FgSQMC": "main-yourLibraryX-filters", - "msaOP0MYt3paJGpbdeJs": "main-yourLibraryX-filters", - "UvXqRORKQr_N3jlgGTcS": "main-yourLibraryX-header", - "HjPqU_UW2egr14mRSom9": "main-yourLibraryX-header", - "j8iKBDzqTDtnDv4XbmrK": "main-yourLibraryX-headerContent", - "tAfozCQs48q1JYdphYXi": "main-yourLibraryX-headerContent", - "O2Vp_sNHMLHUcgMPVnOA": "main-yourLibraryX-headerIsCollapsed", - "TxO7Ee8iwqBpkgznKHsd": "main-yourLibraryX-iconOnly", - "TiJahFhH6KZaibhRtEOA": "main-yourLibraryX-isFlattened", - "y2UicQnlTq148rL8Y0jp": "main-yourLibraryX-isScrolled", - "hjb8tUL3rpUa0ez4ZtAj": "main-yourLibraryX-library", - "wM72343CksOCaL3bZvKK": "main-yourLibraryX-library", - "g581mszC8syz99uMMWsr": "main-yourLibraryX-libraryAfterDrop", - "tXkgloQ88DHF_inQE69J": "main-yourLibraryX-libraryBeforeDrop", - "PpUTJL2NIYDUnmfzVIbE": "main-yourLibraryX-libraryContainer", - "hgJel0bLlS_1Uf0EIfSA": "main-yourLibraryX-libraryContainer", - "_0FuodatXU4_fToYAuYtY": "main-yourLibraryX-libraryFilter", - "uBqliBvyhxGsiql8_OJv": "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", - "oLOECYtBhVmBtyisKwew": "main-yourLibraryX-libraryOnDrop", - "ifVI2CEdOZGgMWIUN2Cw": "main-yourLibraryX-libraryRootlist", - "_W_0W9Uld1vxrRfsgdQR": "main-yourLibraryX-libraryRootlist", - "RGofdOZulhL2p9MRA5hg": "main-yourLibraryX-librarySort", - "XZRX_ea9eNn4rOLpNGLp": "main-yourLibraryX-librarySortWrapper", - "GG5skerNjHXAO6tXyyY0": "main-yourLibraryX-librarySortWrapper", - "qEiVyQ28VnOKb0LeijqL": "main-yourLibraryX-listItem", - "vSC5QuwmzUhqUNWdMTJ5": "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", - "p1ULRzPc4bD8eQ4T_wyp": "playback-progressbar", - "DFtdzavKSbEhwKYkPTa6": "playback-progressbar-isInteractive", - "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", - "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", - "xgmjVLxjqfcXK5BV_XyN": "playlist-playlist-playlistDescription", - "fUYMR7LuRXv0KJWFvRZA": "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", - "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", - "Ws8Ec3GREpT5PAUesr9b": "profile-editImage-imageContainer", - "zHeo4VUxytwm6Ptr0QyA": "profile-userEditDetails-closeButton", - "XwNfIrI6_hCa_9_T2cQB": "profile-userEditDetails-container", - "so0bdX3oZH6YW5_nGxIR": "profile-userEditDetails-content", - "zGbjZMZ1DTx4futEbN9l": "profile-userEditDetails-disclaimer", - "aM3plU4zzDqjWlvUHGYb": "profile-userEditDetails-header", - "F8_EX1AeKxXNSeh1qiHq": "profile-userEditDetails-image", - "wvLAEV5wF5C5ej6rvimT": "profile-userEditDetails-label", - "gAQfzAUp1FuSXODeZJfP": "profile-userEditDetails-labelText", - "uj7hczcCH1dZpse8Kfmi": "profile-userEditDetails-name", - "oN9QVvJKEtdTH3HGfCu1": "profile-userEditDetails-nameInput", - "MDb7QhAtHeyM4gKj8j8t": "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", - "Vis45PPawTyED7Lt2_LI": "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", - "nRSfonXHVr6utXYgk2Ui": "Root__globalNav", - "wp7mZFPzV7Qmo51F0NA_": "Root__globalNav", - "jEMA2gVoLgPQqAFrPhFw": "Root__main-view", - "mMjg1Gizg9kYk8ILoTdp": "Root__main-view-overlay", - "BdcvqBAid96FaHAmPYw_": "Root__nav-bar", - "JG5J9NWJkaUO9fiKECMA": "Root__now-playing-bar", - "OTfMDdomT5S7B5dbYTT8": "Root__right-sidebar", - "PHgyArRLVFknlaOm31ID": "Root__top-bar", - "ZQftYELq0aOsg6tPbVbV": "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", - "kQ22nY00NOOrZjfmRP5J": "search-modal-inputContainer", - "sKrYQkHlFOyAc0bM142q": "search-modal-isSelected", - "EieXgtfUJKc4XQugVglV": "search-modal-key", - "DoibN62ZFcOCvZ0f6xK5": "search-modal-modalAfterOpen", - "_ZcO2wuO5d8P3TbjEukA": "search-modal-modalBase", - "s75RW4QQV6LZ_NCdFtag": "search-modal-modalBeforeClose", - "_p8ywioveAdTZ8yZmPfr": "search-modal-modalWrapper", - "kUkjSLUuPyag37OAbVPH": "search-modal-resultItem", - "zi377dMLSwXnFiejYnRa": "search-modal-searchBar", - "HN_3fmk5t15DGlzDbx1_": "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", - "XTk61Y8OkBdUT6Wj4F6i": "search-searchCategory-carouselButton", - "VfDGbMWaJe9rcefizTNk": "search-searchCategory-carouselButtonVisible", - "ZWI7JsjzJaR_G8Hy4W6J": "search-searchCategory-categoryGridItem", - "UnwG2v9ISmcUhnjKj22Y": "search-searchCategory-categoryGridItem", - "KjPUGV8uMbl_0bvk9ePv": "search-searchCategory-catergoryGrid", - "e179_Eg8r7Ub6yjjxctr": "search-searchCategory-container", - "XAwhJzXeTM5Iv2jLMCEj": "search-searchCategory-container", - "bMurPtRDRv5LuN78MTVG": "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", - "vpQWUBWS_lXRLZMRJT7w": "view-homeShortcutsGrid-episodeExtraContent", - "GSv7K805J9Jw7LB9tn2A": "view-homeShortcutsGrid-episodeProgressBar", - "jxXIarsEHgz2HoaVCVzA": "view-homeShortcutsGrid-equaliser", - "nlOU1unbFAd7ZHyeSMTH": "view-homeShortcutsGrid-grid", - "jdqzCrz9SoQp3ZUsU08w": "view-homeShortcutsGrid-gridOf4Columns", - "ima0EKmsnCNUG08T82EM": "view-homeShortcutsGrid-iconDownloaded", - "WWDxafTPs4AgThdcX5jN": "view-homeShortcutsGrid-image", - "jvWzgRWM_y_9FFTYRCcB": "view-homeShortcutsGrid-imageContainer", - "zXwER4Lsqq_e7fVVaPkZ": "view-homeShortcutsGrid-imageWrapper", - "Tzzq1pG_inwo_oSOdyjb": "view-homeShortcutsGrid-main", - "JFDEiqT_8B5zeG_CDSdK": "view-homeShortcutsGrid-name", - "EzRmGRncgnv1zFgF4dqE": "view-homeShortcutsGrid-name", - "TbrIq3NG2VYFoAUMSmp9": "view-homeShortcutsGrid-nameWrapper", - "vq0lsCoYrDUDvkuUIaRg": "view-homeShortcutsGrid-playButton", - "Kcb74zm1aMqGfPxTwO5s": "view-homeShortcutsGrid-PlayButtonContainer", - "s9c9x_mJq197U2hBzGtV": "view-homeShortcutsGrid-PlayButtonContainerVisible", - "DqAHiYCkcPCcDorezUKI": "view-homeShortcutsGrid-recentlyPlayedShortcutIcon", - "Z35BWOA10YGn5uc9YgAp": "view-homeShortcutsGrid-shortcut", - "jb9xD5ECTqKFK02qe3HZ": "view-homeShortcutsGrid-shortcutLink", - "bPmmKmSPLKMhtJSaUJRX": "view-homeShortcutsGrid-shortcutNewEpisodeIndicator", - "rPV8BmHZXaGIGT2HwvBB": "view-homeShortcutsGrid-shortcuts", - "sLw5dxB32cAxVxqiar7J": "view-homeShortcutsGrid-title", - "lt7UxuNqHQBCO6IWyA_G": "view-homeShortcutsGrid-trailingIndicator", - "eEZSnYlv7__34b2yulfm": "volume-bar", - "G4n5bTzWUvlftzDwrFVG": "volume-bar", - "FZhaXNtbN3Crwrgd0TA7": "volume-bar__icon-button", - "rT09bwCEwXECMbLbX_7A": "volume-bar__icon-button", - "x1jWng8HDweDS840aiIA": "volume-bar__slider-container", - "tIr7C6B0Pt6YKdOnqaqj": "volume-bar__slider-container", - "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", - "rEx3EYgBzS8SoY7dmC6x": "x-downloadButton-progress", - "_APVWqivXc4YqgsnpFkP": "x-downloadButton-removeDownload", - "OadpZJiOaGfX6Qp4j6n5": "x-entityImage-circle", - "AeEoI6ueagbJtaHl2cRd": "x-entityImage-defaultSize", - "iJp40IxKg6emF6KYJ414": "x-entityImage-image", - "vreceNX3ABcxyddeS83B": "x-entityImage-imageContainer", - "Ozitxbqs1vcOukDz3GDw": "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", - "EsqEJ_NPrHsPkTbX4FW8": "x-filterBox-clearButton", - "wCl7pMTEE68v1xuZeZiB": "x-filterBox-expandButton", - "t6HIrX67Lp80Nj6tGauz": "x-filterBox-expandedOrHasFilter", - "KAydWoHSkQRqhQ1vkVwK": "x-filterBox-expandRight", - "QZhV0hWVKlExlKr266jo": "x-filterBox-filterInput", - "JzZyf6OGCGtdscOZGt8Y": "x-filterBox-filterInputContainer", - "iiNt4l0UwY2wL42vyBc2": "x-filterBox-fullWidth", - "uAJxc_psYWeimY8N9bH9": "x-filterBox-overlay", - "CIVozJ8XNPJ60uMN23Yg": "x-filterBox-searchIcon", - "_bjbHn5TABOW2s5LsEGX": "x-filterBox-searchIconContainer", - "epWhU7hHGktzlO_dop6z": "x-progressBar-fillColor", - "NoOAOv6U6vtqj_ybS1Cd": "x-progressBar-progressBarBg", - "w699O0LgQRghXyl3bs9u": "x-progressBar-sliderArea", - "AavUTirpKSAHapguSAy0": "x-proxySettings-fullWidth", - "qfjicQPaTTPrLWJWRxMQ": "x-proxySettings-horizontalPair", - "acVhMt5pELcXQyLaaPuV": "x-proxySettings-ProxySettings", - "fOEOTcOAgPryvbYRYfOo": "x-searchHistoryEntries-clearSingleSearchHistory", - "xmJl0s8mcJ3bfhtnoaP1": "x-searchHistoryEntries-clearSingleSearchHistoryButton", - "ADri2r8kq8LVqSsNNvIr": "x-searchHistoryEntries-searchHistoryEntry", - "ZtY42R4YSo_W7VMeAg9m": "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", - "XD3TMuMHmKsfbqieC6q_": "x-searchInput-searchInputOnSearch", - "H6jh9Xd7DNOq3NsLDmCB": "x-searchInput-searchInputSearchIcon", - "aCQCOVyiFROs_qr_DvQ4": "x-searchInput-searchInputSearchIcon", - "rFFJg1UIumqUUFDgo6n7": "x-settings-button", - "LOsH9AUZc2uFRlhqtpRT": "x-settings-container", - "xbm3VfL2kntDlxtyDKwj": "x-settings-crossFadeContainer", - "gv7Rcc2ouDRSd0pto7Df": "x-settings-equalizerPanelCanvas", - "zrn877LGjVA_oYp2IKeu": "x-settings-equalizerPanelFilter", - "aOnWJeLmOj8plFB5QPSt": "x-settings-equalizerPanelFilters", - "L9sAZBDUVTnJLn7TqR1E": "x-settings-equalizerPanelGainLabel", - "aPIPHZU8F7TMi6LEw_Yq": "x-settings-equalizerPanelGainLabelDown", - "YKGOZh1y8HVOqQgbJB9F": "x-settings-equalizerPanelGainLabelUp", - "KGnDzV9IPjGQ8Ude8Cgl": "x-settings-equalizerPanelInput", - "NWmefh0djDBBQmHr_mdf": "x-settings-equalizerPanelLabel", - "PumO62RLWafcexCJx0oe": "x-settings-equalizerPanelPreset", - "HGN6qJ4noxOV2YSJWj3g": "x-settings-equalizerPanelWrapper", - "j7cPtD65ArW8eWnGNrUo": "x-settings-equalizerPresetsContainer", - "ZzNb8P1Bz1VHtlhimIWM": "x-settings-equalizerPresetsLabel", - "PiFWoUIRceOm8SHTCakS": "x-settings-equalizerResetButtonWrapper", - "xK6HEWejcSHKyWfhNiJc": "x-settings-equalizerSection", - "FulR95cAh4QPdw6wUeRw": "x-settings-equalizerWrapper", - "GMGmbx5FRBd6DOVvzSgk": "x-settings-firstColumn", - "g2SG95QPZfbn5RINccth": "x-settings-firstColumn", - "NnDXf1J9xlVM5AUuqVt1": "x-settings-header", - "DQ9fp5DjBJxKHeHqtFwC": "x-settings-headerContainer", - "V4Bh2Ch7KvYUdn2s9ZdX": "x-settings-hidden", - "SkbGMKYv49KtJNB5XxdX": "x-settings-input", - "gvcgOXnAiNKEe_z92_lw": "x-settings-restartAppButton", - "weV_qxFz4gF5sPotO10y": "x-settings-row", - "BMtRRwqaJD_95vJFMFD0": "x-settings-row", - "yNitN64xoLNhzJlkfzOh": "x-settings-secondColumn", - "rtzkwMH3kqwgnS_BxP_t": "x-settings-secondColumn", - "c6TyNYOUJRIsjYZJZofy": "x-settings-section", - "k6GFKyOKVR5Ruofj3aTQ": "x-settings-section", - "GuwMf98GUBSpCDgf8KRA": "x-settings-tooltip", - "nW1RKQOkzcJcX6aDCZB4": "x-settings-tooltipIcon", - "qBZYab2T7Yc4O5Nh0mjA": "x-settings-tooltipIconWrapper", - "l35q2le3C8eAxw1TKELD": "x-settings-wordBreakAll", - "w6j_vX6SF5IxSXrrkYw5": "x-sortBox-sortDropdown", - "es9mguuOfkp6pBe1Bjlw": "x-toggle-indicator", - "Js64TOfWtHksI6TQ6knT": "x-toggle-indicatorWrapper", - "n8tsDTs8wDH73kejYfXs": "x-toggle-input", - "JWYoNAyrIIdW30u4PSGE": "x-toggle-wrapper" -} diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000000..cae92409b9 --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,11 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package errors + +import "errors" + +var ErrUnsupportedOperation = errors.New("this opperation is not supported") +var ErrPathNotFound = errors.New("couldn't find path") diff --git a/globals.d.ts b/globals.d.ts deleted file mode 100644 index 73b028050a..0000000000 --- a/globals.d.ts +++ /dev/null @@ -1,2358 +0,0 @@ -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; - 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; - "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; - }; - 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 { - /** - * 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<{ - 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); - /** - * Remove an item from sub items list - */ - removeItem(item: Item); - /** - * 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 - */ - content: string | 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; - - /** 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)) { - changeValue(name, remoteConfiguration.values.get(name)); - console.log(name, remoteConfiguration.values.get(name), overrideList[name]); - } - - 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); - - setOverrides(Spicetify.createInternalMap?.(featureMap)); - })(); - - 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(); -})(); diff --git a/jsHelper/homeConfig.js b/jsHelper/homeConfig.js deleted file mode 100644 index 7b5490df83..0000000000 --- a/jsHelper/homeConfig.js +++ /dev/null @@ -1,211 +0,0 @@ -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); - if (index !== -1) { - const item = sections[index]; - statusDic[item.uri] = STICKY; - stickSections.push(item); - sections[index] = undefined; - } - } - for (const uri of lowList) { - const index = sections.findIndex(a => a?.uri === uri); - if (index !== -1) { - const item = sections[index]; - statusDic[item.uri] = LOWERED; - lowSections.push(item); - sections[index] = undefined; - } - } - sections = sections.filter(Boolean); - - list = [...stickSections, ...sections, ...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; - } - - 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(); - } - }); -})(); diff --git a/jsHelper/sidebarConfig.js b/jsHelper/sidebarConfig.js deleted file mode 100644 index 73c5c113e5..0000000000 --- a/jsHelper/sidebarConfig.js +++ /dev/null @@ -1,361 +0,0 @@ -(function SidebarConfig() { - const sidebar = document.querySelector(".Root__nav-bar"); - if (!sidebar) return setTimeout(SidebarConfig, 100); - // Status enum - const HIDDEN = 0; - const SHOW = 1; - const STICKY = 2; - // Store sidebar buttons elements - let appItems; - let list; - let hiddenList; - - let isYLX; - 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]]); - - if (isYLX) return localStorage.setItem("spicetify-sidebar-config:ylx", JSON.stringify(array)); - return localStorage.setItem("spicetify-sidebar-config", 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(); - } - - if (isYLX) { - 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 (isYLX) { - 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` - ); - } else document.documentElement.style.setProperty("--nav-bar-width", `${Spicetify.Platform.LocalStorageAPI.getItem("nav-bar-width")}px`); - writeStorage(); - } - - (async () => { - await new Promise(res => Spicetify.Events.webpackLoaded.on(res)); - 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"); - const libraryLegacy = document.querySelector(".main-navBar-entryPoints"); - - if (!libraryLegacy && !libraryX) { - setTimeout(initConfig, 300); - return; - } - - if (libraryX) InitSidebarXConfig(); - else InitSidebarConfig(); - } - - function InitSidebarConfig() { - // STICKY container - const legacyAppItems = document.querySelector(".main-navBar-entryPoints"); - const rootList = document.querySelector(".main-rootlist-rootlist"); - const playlistItems = document.querySelector(".main-navBar-navBar .os-content"); - - if (!legacyAppItems || !playlistItems || !rootList) { - setTimeout(InitSidebarConfig, 300); - return; - } - - appItems = legacyAppItems; - buttons = []; - ordered = []; - isYLX = false; - - 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 = playlistItems.querySelector("ul"); - playlistList.id = "spicetify-playlist-list"; - playlistItems.prepend(list, hiddenList); - - for (const ele of appItems.children) { - ele.dataset.id = ele.querySelector("a")?.pathname ?? "/add"; - buttons.push(ele); - } - - for (const ele of rootList.querySelectorAll("div.GlueDropTarget")) { - if (ele.classList.contains("GlueDropTarget--playlists")) break; - const link = ele.querySelector("a"); - if (!link) { - ele.dataset.id = "/add"; - } else { - ele.dataset.id = link.pathname; - } - ele.classList.add("personal-library"); - new MutationObserver(mutations => { - for (const mutation of mutations) { - if (mutation.type === "attributes" && mutation.attributeName === "class") { - if (!mutation.target.classList.contains("personal-library")) { - mutation.target.classList.add("personal-library"); - } - } - } - }).observe(ele, { attributes: true, attributeFilter: ["class"] }); - - buttons.push(ele); - } - - let storage = []; - try { - storage = JSON.parse(localStorage.getItem("spicetify-sidebar-config")); - if (!Array.isArray(storage)) throw ""; - } catch { - storage = buttons.map(el => [el.dataset.id, STICKY]); - } - - arrangeItems(storage); - appendItems(); - } - - 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 = []; - isYLX = true; - - 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(); - - // Rearrange sidebar when dynamically switching in Experimental Features - new MutationObserver(mutations => { - for (const mutation of mutations) { - if (mutation.attributeName === "class") { - if (mutation.target.classList.contains("hasYLXSidebar") || !!mutation.target.querySelector(".main-yourLibraryX-entryPoints")) { - InitSidebarXConfig(); - } else { - InitSidebarConfig(); - } - } - } - }).observe(sidebar, { childList: true, attributes: true, attributeFilter: ["class"] }); - - 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); -})(); diff --git a/jsHelper/spicetifyWrapper.js b/jsHelper/spicetifyWrapper.js deleted file mode 100644 index 2a44c3a703..0000000000 --- a/jsHelper/spicetifyWrapper.js +++ /dev/null @@ -1,2528 +0,0 @@ -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 => { - if (p <= 1) { - p = Math.round(p * Spicetify.Player.origin._state.duration); - } - Spicetify.Player.origin.seekTo(p); - }, - 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", - "getFontStyle", - "_fontStyle", - "Config", - "expFeatureOverride", - "createInternalMap", - "RemoteConfigResolver", - "Playbar", - "Tippy", - "_getStyledClassName", - "GraphQL", - "ReactHook", - "AppTitle", - "_reservedPanelIds", - "ReactFlipToolkit", - "classnames", - "ReactQuery", - "Color", - "extractColorPreset", - "ReactDOMServer", - "Snackbar", - "ContextMenuV2", - "ReactJSX", - "_renderNavLinks" - ]) - }, - { - 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 addProxyCosmos() { - if (!Spicetify.Player.origin?._cosmos) { - setTimeout(addProxyCosmos, 50); - return; - } - - const corsProxyURL = "https://cors-proxy.spicetify.app"; - 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) || Spicetify.Platform.version < "1.2.31") return internalFetch; - - return async function (url, body) { - const urlObj = new URL(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); - finalURL += `?${params.toString()}`; - } else options.body = !Array.isArray(body) && typeof body === "object" ? JSON.stringify(body) : body; - } - if (shouldUseCORSProxy) finalURL = `${corsProxyURL}/${finalURL}`; - - 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); - } - }; - } - }; - - Spicetify.Player.origin._cosmos = new Proxy(Spicetify.Player.origin._cosmos, handler); - Object.defineProperty(Spicetify, "CosmosAsync", { - get: () => { - return Spicetify.Player.origin?._cosmos; - } - }); -})(); - -(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]; - } - } - Spicetify.Events.platformLoaded.fire(); -})(); - -(function hotloadWebpackModules() { - if (!window?.webpackChunkclient_web) { - setTimeout(hotloadWebpackModules, 50); - return; - } - // Force all webpack modules to load - const require = webpackChunkclient_web.push([[Symbol()], {}, re => re]); - const chunks = require.m ? Object.entries(require.m) : []; - if (!chunks) { - setTimeout(hotloadWebpackModules, 50); - 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 {} - }); - // 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 functionModules = modules.filter(module => typeof module === "function"); - 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) => s.toString().match(componentRegexes[i])), f]); - return Object.fromEntries(componentPairs); - }; - const reactComponentsUI = exposeReactComponentsUI({ modules, functionModules, exportedForwardRefs }); - - const knownMenuTypes = ["album", "show", "artist", "track"]; - const menus = modules - .map(m => m?.type?.toString().match(/value:"[\w-]+"/g) && [m, ...m.type.toString().match(/value:"[\w-]+"/g)]) - .filter(Boolean) - .filter(m => m[1] !== 'value:"row"') - .map(([module, type]) => { - type = type.match(/value:"([\w-]+)"/)[1]; - - 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 (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) - ]; - - 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), - // classnames for 1.2.13 - classnames: cache - .filter(module => typeof module === "function") - .find(module => module.toString().includes('"string"') && module.toString().includes("[native code]")), - Color: functionModules.find(m => m.toString().includes("static fromHex") || m.toString().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 => m.toString().includes("subscription") && m.toString().includes("mutation")), - Handler: functionModules.find(m => m.toString().includes("GraphQL subscriptions are not supported")) - }, - ReactComponent: { - ...Spicetify.ReactComponent, - TextComponent: modules.find(m => m?.h1 && m?.render), - ConfirmDialog: functionModules.find( - m => m.toString().includes("isOpen") && m.toString().includes("shouldCloseOnEsc") && m.toString().includes("onClose") - ), - Menu: functionModules.find(m => m.toString().includes("getInitialFocusElement") && m.toString().includes("children")), - MenuItem: functionModules.find(m => m.toString().includes("handleMouseEnter") && m.toString().includes("onClick")), - MenuSubMenuItem: functionModules.find(f => f.toString().includes("subMenuIcon")), - Slider: wrapProvider(functionModules.find(m => m.toString().includes("onStepBackward") && !m.toString().includes("volume"))), - RemoteConfigProvider: functionModules.find(m => m.toString().includes("resolveSuspense") && m.toString().includes("configuration")), - RightClickMenu: functionModules.find( - m => - m.toString().includes("action") && m.toString().includes("open") && m.toString().includes("trigger") && m.toString().includes("right-click") - ), - TooltipWrapper: functionModules.find(m => m.toString().includes("renderInline") && m.toString().includes("showDelay")), - ButtonPrimary: reactComponentsUI.ButtonPrimary, - ButtonSecondary: reactComponentsUI.ButtonSecondary, - ButtonTertiary: reactComponentsUI.ButtonTertiary, - Snackbar: { - wrapper: functionModules.find(m => m.toString().includes("encore-light-theme") && m.toString().includes("elevated")), - simpleLayout: functionModules.find(m => ["leading", "center", "trailing"].every(keyword => m.toString().includes(keyword))), - ctaText: functionModules.find(m => m.toString().includes("ctaText")), - styledImage: functionModules.find(m => m.toString().includes("placeholderSrc")) - }, - Chip: reactComponentsUI.Chip, - Toggle: functionModules.find(m => m.toString().includes("onSelected") && m.toString().includes('type:"checkbox"')), - Cards: { - Default: reactComponentsUI.Card, - FeatureCard: functionModules.find( - m => m.toString().includes("?highlight") && m.toString().includes("headerText") && m.toString().includes("imageContainer") - ), - Hero: functionModules.find(m => m?.toString().includes('"herocard-click-handler"')), - CardImage: functionModules.find( - m => - m.toString().includes("isHero") && - (m.toString().includes("withWaves") || m.toString().includes("isCircular")) && - m.toString().includes("imageWrapper") - ), - ...Object.fromEntries(cards) - }, - Router: functionModules.find(m => m.toString().includes("navigationType") && m.toString().includes("static")), - Routes: functionModules.find(m => m.toString().match(/\([\w$]+\)\{let\{children:[\w$]+,location:[\w$]+\}=[\w$]+/)), - Route: functionModules.find(m => m.toString().match(/^function [\w$]+\([\w$]+\)\{\(0,[\w$]+\.[\w$]+\)\(\!1\)\}$/)), - StoreProvider: functionModules.find(m => m.toString().includes("notifyNestedSubs") && m.toString().includes("serverState")), - Navigation: exportedMemoFRefs.find(m => m.type.render.toString().includes("navigationalRoot")), - ScrollableContainer: functionModules.find(m => m.toString().includes("scrollLeft") && m.toString().includes("showButtons")), - IconComponent: reactComponentsUI.Icon, - ...Object.fromEntries(menus) - }, - ReactHook: { - DragHandler: functionModules.find(m => m.toString().includes("dataTransfer") && m.toString().includes("data-dragging")), - useExtractedColor: functionModules.find( - m => m.toString().includes("extracted-color") || (m.toString().includes("colorRaw") && m.toString().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 => m.toString().includes("persistOptions")), - QueryClient: functionModules.find(m => m.toString().includes("defaultMutationOptions")), - QueryClientProvider: functionModules.find(m => m.toString().includes("use QueryClientProvider")), - notifyManager: modules.find(m => m?.setBatchNotifyFunction), - useMutation: functionModules.find(m => m.toString().includes("mutateAsync")), - useQuery: functionModules.find(m => - m.toString().match(/^function [\w_$]+\(([\w_$]+),([\w_$]+)\)\{return\(0,[\w_$]+\.[\w_$]+\)\(\1,[\w_$]+\.[\w_$]+,\2\)\}$/) - ), - useQueryClient: functionModules.find( - m => m.toString().includes("client") && m.toString().includes("Provider") && m.toString().includes("mount") - ), - useSuspenseQuery: functionModules.find( - m => m.toString().includes("throwOnError") && m.toString().includes("suspense") && m.toString().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({}); - - (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 => m.toString().includes("enqueueSnackbar called with invalid argument")), - useSnackbar: functionModules.find(m => m.toString().match(/^function\(\)\{return\(0,[\w$]+\.useContext\)\([\w$]+\)\}$/)) - }; - })(); - - const localeModule = modules.find(m => m?.getTranslations); - if (localeModule) { - const createUrlLocale = functionModules.find( - m => m.toString().includes("has") && m.toString().includes("baseName") && m.toString().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; - } - }); - - // classnames - // https://github.com/JedWatson/classnames/ - const classnamesChunk = chunks.find(([_, value]) => value.toString().includes("[native code]") && !value.toString().includes("")); - if (classnamesChunk && !Spicetify.classnames) { - Spicetify.classnames = Object.values(require(classnamesChunk[0])).find(m => typeof m === "function"); - } - - const contextMenuChunk = chunks.find(([, value]) => value.toString().includes("toggleContextMenu")); - 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 = Object.values(require(playlistMenuChunk[0])).find(m => typeof m === "function" || typeof m === "object"); - } - - const dropdownChunk = chunks.find(([, value]) => value.toString().includes("dropDown") && value.toString().includes("isSafari")); - if (dropdownChunk) { - Spicetify.ReactComponent.Dropdown = Object.values(require(dropdownChunk[0])).find(m => typeof m === "function"); - } - - 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?.AD); - } catch { - return false; - } - }); - const URIModules = Object.values(URIChunk); - - // URI.Type - Spicetify.URI.Type = URIModules.find(m => m?.AD); - - // 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; - })(); - - 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 => { - uri = uri || Spicetify.Player.data.item.uri; - const uriObj = Spicetify.URI.from?.(uri) ?? Spicetify.URI.fromString?.(uri); - 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" - ]; - 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", "$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; -}; - -Spicetify.getFontStyle = font => { - if (!font || !Spicetify._fontStyle) return; - let rawStyle = Spicetify._fontStyle({ variant: font, $variant: font }) - .filter(style => typeof style === "string") - .join(""); - // Clean up empty rulesets - rawStyle = rawStyle.replace(/\w+-\w+:;/g, "").trim(); - // Split special rulesets - const mediaStyle = rawStyle.split("@"); - let returnStyle = `.main-type-${font}`; - - mediaStyle.map((ruleset, index) => { - if (index === 0) { - returnStyle += `{${ruleset}}`; - return; - } - let newRuleset; - if (ruleset.endsWith(";")) newRuleset = ruleset.slice(0, -1); - newRuleset = ruleset.split(")").join(`){.main-type-${font}`); - returnStyle += `@${newRuleset}}`; - return; - }); - - if (returnStyle.endsWith(";")) returnStyle = returnStyle.slice(0, -1); - return returnStyle.replaceAll(";;", ";"); -}; - -(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: - '"': '"', - "~": "`", - "!": "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 appendAllFontStyle() { - if (!Spicetify._fontStyle) { - setTimeout(appendAllFontStyle, 1000); - return; - } - const fontList = Spicetify._fontStyle - .toString() - .match(/"\w+"/g) - .map(font => font.replaceAll('"', "")); - const fontStyle = document.createElement("style"); - fontStyle.className = "spicetify-font"; - for (const font of fontList) { - fontStyle.innerHTML += Spicetify.getFontStyle(font); - } - return document.head.appendChild(fontStyle); -})(); - -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 => items.map(item => item._element); - - constructor({ text, disabled = false, leadingIcon, divider, items, shouldAdd = () => true }) { - this.shouldAdd = shouldAdd; - - this._text = text; - this._disabled = disabled; - this._leadingIcon = leadingIcon; - this._items = items; - 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); - - Spicetify.React.useEffect(() => { - this._setText = setText; - this._setDisabled = setDisabled; - this._setLeadingIcon = setLeadingIcon; - this._setDivider = setDivider; - this._setItems = setItems; - return () => { - this._setText = undefined; - this._setDisabled = undefined; - this._setLeadingIcon = undefined; - this._setDivider = undefined; - this._setItems = undefined; - }; - }); - - return Spicetify.React.createElement(Spicetify.ReactComponent.MenuSubMenuItem, { - displayText: _text, - divider: _divider, - depth: 1, - placement: "right-start", - onOpenChange: () => undefined, - onClick: () => undefined, - disabled: _disabled, - leadingIcon: _leadingIcon && createIconComponent(_leadingIcon), - children: ItemSubMenu.itemsToComponents(_items) - }); - }, {}); - } - - 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; - } - - 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, isPreLibX = false) => { - const [refreshCount, refresh] = Spicetify.React.useReducer(x => x + 1, 0); - refreshNavLinks = refresh; - - if ( - !Spicetify.ReactComponent.ButtonTertiary || - !Spicetify.ReactComponent.Navigation || - !Spicetify.ReactComponent.TooltipWrapper || - !Spicetify.Platform.History || - !Spicetify.Platform.LocalStorageAPI - ) - return; - - const navLinkFactory = isTouchScreenUi ? NavLinkGlobal : isPreLibX ? NavLinkSidebarLegacy : 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 }); - } - - const style = document.createElement("style"); - 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 NavLinkSidebarLegacy = ({ appProper, appRoutePath, createIcon, isActive }) => { - return Spicetify.React.createElement( - "li", - { className: "main-navBar-navBarItem InvalidDropTarget" }, - Spicetify.React.createElement( - Spicetify.ReactComponent.TooltipWrapper, - { label: appProper, placement: "right" }, - Spicetify.React.createElement( - Spicetify.ReactComponent.Navigation, - { - to: appRoutePath, - referrer: "other", - className: Spicetify.classnames("link-subtle", "main-navBar-navBarLink", { - "main-navBar-navBarLinkActive active": isActive - }), - onClick: () => undefined, - "aria-label": appProper - }, - createIcon(), - Spicetify.React.createElement(Spicetify.ReactComponent.TextComponent, { variant: "mestoBold" }, appProper) - ) - ) - ); -}; - -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() { - 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 leftContainer; - let rightContainer; - const leftButtonsStash = new Set(); - const rightButtonsStash = new Set(); - const generatedClassName = "Button-medium-medium-buttonTertiary-iconOnly-condensed-disabled-isUsingKeyboard-useBrowserDefaultFocusStyle"; - - 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); - const globalHistoryButtons = document.querySelector(".main-globalNav-historyButtons"); - if (isRight) { - this.button.classList.add("encore-over-media-set", "main-topBar-buddyFeed"); - if (globalHistoryButtons) this.button.classList.add("main-globalNav-buddyFeed"); - - rightButtonsStash.add(this.element); - rightContainer?.prepend(this.element); - } else { - this.button.classList.add("main-topBar-button"); - if (globalHistoryButtons) { - this.button.classList.add("main-globalNav-icon", generatedClassName); - } - - 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"); - leftContainer = document.querySelector(".main-topBar-historyButtons") ?? globalHistoryButtons; - rightContainer = document.querySelector(".main-actionButtons"); - if (!leftContainer || !rightContainer) { - 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"); - if (globalHistoryButtons) { - buttonElement.classList.add("main-globalNav-icon", generatedClassName); - } else { - buttonElement.classList.remove("main-globalNav-icon", generatedClassName); - } - } - leftContainer.append(...leftButtonsStash); - for (const button of rightButtonsStash) { - if (button.parentNode) button.parentNode.removeChild(button); - - const buttonElement = button.querySelector("button"); - if (globalHistoryButtons) buttonElement.classList.add("main-globalNav-buddyFeed"); - else buttonElement.classList.remove("main-globalNav-buddyFeed"); - } - 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/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); - } -})(); diff --git a/link/link_unix.go b/link/link_unix.go new file mode 100644 index 0000000000..7211490480 --- /dev/null +++ b/link/link_unix.go @@ -0,0 +1,14 @@ +//go:build unix + +/* + * Copyright (C) 2024 OhItsTom + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package link + +import "os" + +func Create(oldname string, newname string) error { + return os.Symlink(oldname, newname) +} diff --git a/link/link_windows.go b/link/link_windows.go new file mode 100644 index 0000000000..0ccee1a3ce --- /dev/null +++ b/link/link_windows.go @@ -0,0 +1,71 @@ +//go:build windows + +/* + * Copyright (C) 2024 OhItsTom + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package link + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Microsoft/go-winio" + "golang.org/x/sys/windows" +) + +func Create(target, mountPt string) error { + _target, err := filepath.Abs(target) + if err != nil { + return fmt.Errorf("failed to get absolute path of target %s: %v", target, err) + } + _mountPt, err := windows.UTF16PtrFromString(mountPt) + if err != nil { + return fmt.Errorf("failed to get UTF16 pointer of mount point %s: %v", mountPt, err) + } + + err = os.Mkdir(mountPt, 0777) + if err != nil { + return fmt.Errorf("failed to create directory %s: %v", mountPt, err) + } + defer func() { + if err != nil { + os.Remove(mountPt) + } + }() + + handle, err := windows.CreateFile(_mountPt, + windows.GENERIC_WRITE, + 0, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS, + 0) + if err != nil { + return fmt.Errorf("failed to create file handle for %s: %v", mountPt, err) + } + defer windows.CloseHandle(handle) + + rp := winio.ReparsePoint{ + Target: _target, + IsMountPoint: true, + } + data := winio.EncodeReparsePoint(&rp) + + var size uint32 + err = windows.DeviceIoControl( + handle, + windows.FSCTL_SET_REPARSE_POINT, + &data[0], + uint32(len(data)), + nil, + 0, + &size, + nil) + if err != nil { + return fmt.Errorf("failed to set reparse point: %v", err) + } + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000000..1fd8c0eb1c --- /dev/null +++ b/main.go @@ -0,0 +1,14 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package main + +import ( + "spicetify/cmd" +) + +func main() { + cmd.Execute() +} diff --git a/manifest.json b/manifest.json deleted file mode 100644 index 9b6276dc56..0000000000 --- a/manifest.json +++ /dev/null @@ -1,44 +0,0 @@ -[ - { - "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" - } -] diff --git a/module/metadata.go b/module/metadata.go new file mode 100644 index 0000000000..7d23c9104b --- /dev/null +++ b/module/metadata.go @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package module + +import "path" + +type Metadata struct { + Name string `json:"name"` + Version string `json:"version"` + Authors []string `json:"authors"` + Description string `json:"description"` + Tags []string `json:"tags"` + Entries struct { + Js string `json:"js"` + Css string `json:"css"` + } `json:"entries"` + HasMixins bool `json:"hasMixins"` + Dependencies map[string]string `json:"dependencies"` +} + +func (m *Metadata) getAuthor() string { + return m.Authors[0] +} + +// TODO: avoid usage +func (m *Metadata) GetModuleIdentifier() ModuleIdentifier { + return ModuleIdentifier(path.Join(m.getAuthor(), m.Name)) +} + +// TODO: avoid usage +func (m *Metadata) GetStoreIdentifier() StoreIdentifier { + return StoreIdentifier{ + ModuleIdentifier: m.GetModuleIdentifier(), + Version: Version(m.Version), + } +} diff --git a/module/module.go b/module/module.go new file mode 100644 index 0000000000..e2ac101b9f --- /dev/null +++ b/module/module.go @@ -0,0 +1,251 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package module + +import ( + "archive/zip" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "spicetify/archive" + "spicetify/link" + "spicetify/paths" + "strings" + + bufra "github.com/avvmoto/buf-readerat" + "github.com/snabb/httpreaderat" +) + +type Artifact interface { + GetMetdata() (Metadata, error) + install(storeIdentifier StoreIdentifier, checksum string) error + ToUrl() ArtifactURL +} + +type ProviderURL string +type ArtifactURL string +type RemoteArtifact string +type RemoteMetadataURL string +type LocalArtifact string +type LocalMetadataURL string + +func isUrl(str string) bool { + u, err := url.Parse(str) + return err == nil && strings.HasPrefix(u.Scheme, "http") && u.Host != "" +} + +func (u ArtifactURL) Parse() Artifact { + if isUrl(string(u)) { + return RemoteArtifact(u) + } + return LocalArtifact(u) +} + +func (u RemoteArtifact) GetMetdata() (Metadata, error) { + b, found := strings.CutSuffix(string(u), ".zip") + if !found { + panic("artifact urls must end with .zip") + } + + murl := RemoteMetadataURL(b + ".metadata.json") + + return fetchRemoteMetadata(murl) +} + +func (u RemoteArtifact) install(storeIdentifier StoreIdentifier, checksum string) error { + return downloadModuleToStore(u, storeIdentifier, checksum) +} + +func (u RemoteArtifact) ToUrl() ArtifactURL { + return ArtifactURL(u) +} + +func (u LocalArtifact) GetMetdata() (Metadata, error) { + murl := LocalMetadataURL(filepath.Join(string(u), "metadata.json")) + + return fetchLocalMetadata(murl) +} + +func (u LocalArtifact) install(storeIdentifier StoreIdentifier, checksum string) error { + return ensureSymlink(string(u), storeIdentifier.toPath()) +} + +func (u LocalArtifact) ToUrl() ArtifactURL { + path, err := filepath.Abs(string(u)) + if err != nil { + panic(err) + } + return ArtifactURL(path) +} + +var modulesFolder = filepath.Join(paths.ConfigPath, "modules") +var storeFolder = filepath.Join(paths.ConfigPath, "store") +var vaultPath = filepath.Join(modulesFolder, "vault.json") + +func parseMetadata(r io.Reader) (Metadata, error) { + var metadata Metadata + if err := json.NewDecoder(r).Decode(&metadata); err != nil { + return Metadata{}, err + } + return metadata, nil +} + +func fetchRemoteMetadata(murl RemoteMetadataURL) (Metadata, error) { + res, err := http.Get(string(murl)) + if err != nil { + return Metadata{}, err + } + defer res.Body.Close() + + return parseMetadata(res.Body) +} + +func fetchLocalMetadata(murl LocalMetadataURL) (Metadata, error) { + file, err := os.Open(string(murl)) + if err != nil { + return Metadata{}, err + } + defer file.Close() + + return parseMetadata(file) +} + +func downloadModuleToStore(aurl RemoteArtifact, storeIdentifier StoreIdentifier, checksum string) error { + req, _ := http.NewRequest("GET", string(aurl), nil) + + htrdr, err := httpreaderat.New(nil, req, nil) + if err != nil { + panic(err) + } + bhtrdr := bufra.NewBufReaderAt(htrdr, 1024*1024) + + zrdr, err := zip.NewReader(bhtrdr, htrdr.Size()) + if err != nil { + return err + } + + // TODO: verify checksum + return archive.UnZip(zrdr, storeIdentifier.toPath()) +} + +func deleteModuleFromStore(identifier StoreIdentifier) error { + return os.RemoveAll(identifier.toPath()) +} + +func AddStoreInVault(storeIdentifier StoreIdentifier, store *Store) error { + return MutateVault(func(vault *Vault) bool { + return vault.setStore(storeIdentifier, store) + }) +} + +func InstallModule(storeIdentifier StoreIdentifier) error { + vault, err := GetVault() + if err != nil { + return err + } + + store, ok := vault.getStore(storeIdentifier) + if !ok { + return errors.New("Can't find store " + storeIdentifier.toString()) + } + + // TODO: add more options + aurl := store.Artifacts[0] + + if err := aurl.Parse().install(storeIdentifier, store.Checksum); err != nil { + return err + } + + store.Installed = true + vault.setStore(storeIdentifier, store) + return SetVault(vault) +} + +func EnableModuleInVault(identifier StoreIdentifier) error { + vault, err := GetVault() + if err != nil { + return err + } + + module := vault.getModule(identifier.ModuleIdentifier) + + if module.Enabled == identifier.Version { + return nil + } + + if len(string(identifier.Version)) > 0 { + if _, ok := module.V[identifier.Version]; !ok { + return errors.New("Can't find matching " + identifier.toString()) + } + } + + module.Enabled = identifier.Version + vault.setModule(identifier.ModuleIdentifier, module) + + if len(string(identifier.ModuleIdentifier)) > 0 { + destroySymlink(identifier.ModuleIdentifier) + if len(string(module.Enabled)) > 0 { + if err := createSymlink(identifier); err != nil { + return err + } + } + } + + return SetVault(vault) +} + +func DeleteModule(identifier StoreIdentifier) error { + if err := MutateVault(func(vault *Vault) bool { + module := vault.getModule(identifier.ModuleIdentifier) + + if module.Enabled == identifier.Version { + module.Enabled = "" + destroySymlink(identifier.ModuleIdentifier) + } + + store, ok := module.V[identifier.Version] + if ok { + store.Installed = false + } + + vault.setModule(identifier.ModuleIdentifier, module) + return true + }); err != nil { + return err + } + + return deleteModuleFromStore(identifier) +} + +func RemoveStoreInVault(identifier StoreIdentifier) error { + return MutateVault(func(vault *Vault) bool { + module := vault.getModule(identifier.ModuleIdentifier) + + delete(module.V, identifier.Version) + vault.setModule(identifier.ModuleIdentifier, module) + return true + }) +} + +func ensureSymlink(oldname string, newname string) error { + if err := os.MkdirAll(filepath.Dir(newname), 0755); err != nil { + return err + } + os.Remove(newname) + return link.Create(oldname, newname) +} + +func createSymlink(identifier StoreIdentifier) error { + return ensureSymlink(identifier.toPath(), identifier.ModuleIdentifier.toPath()) +} + +func destroySymlink(identifier ModuleIdentifier) error { + return os.Remove(identifier.toPath()) +} diff --git a/module/vault.go b/module/vault.go new file mode 100644 index 0000000000..58dc60e912 --- /dev/null +++ b/module/vault.go @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package module + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "regexp" +) + +type Store struct { + Installed bool `json:"installed"` + Artifacts []ArtifactURL `json:"artifacts"` + Checksum string `json:"checksum"` +} + +type Author string +type Name string +type Version string +type ModuleIdentifier string + +func (mi ModuleIdentifier) toPath() string { + return filepath.Join(modulesFolder, string(mi)) +} + +type Module struct { + Enabled Version `json:"enabled"` + V map[Version]Store `json:"v"` +} +type Vault struct { + Modules map[ModuleIdentifier]Module `json:"modules"` +} + +func (v *Vault) getModule(identifier ModuleIdentifier) *Module { + module, ok := v.Modules[identifier] + if !ok { + module = Module{ + Enabled: "", + V: map[Version]Store{}, + } + v.Modules[identifier] = module + } + return &module +} + +func (v *Vault) setModule(identifier ModuleIdentifier, module *Module) { + v.Modules[identifier] = *module +} + +func (v *Vault) getEnabledStore(identifier ModuleIdentifier) (*Store, bool) { + module := Store{} + versions := v.getModule(identifier) + module, ok := versions.V[versions.Enabled] + return &module, ok +} + +func (v *Vault) getStore(identifier StoreIdentifier) (*Store, bool) { + versions := v.getModule(identifier.ModuleIdentifier) + store, ok := versions.V[identifier.Version] + return &store, ok +} + +func (v *Vault) setStore(identifier StoreIdentifier, module *Store) bool { + if len(string(identifier.Version)) == 0 { + return false + } + versions := v.getModule(identifier.ModuleIdentifier) + versions.V[identifier.Version] = *module + return true +} + +func GetVault() (*Vault, error) { + file, err := os.Open(vaultPath) + if err != nil { + return &Vault{}, err + } + defer file.Close() + + var vault Vault + err = json.NewDecoder(file).Decode(&vault) + return &vault, err +} + +func SetVault(vault *Vault) error { + vaultJson, err := json.Marshal(vault) + if err != nil { + return err + } + + os.MkdirAll(modulesFolder, os.ModePerm) + return os.WriteFile(vaultPath, vaultJson, 0700) +} + +func MutateVault(mutate func(*Vault) bool) error { + vault, err := GetVault() + if err != nil { + return err + } + + if ok := mutate(vault); !ok { + return errors.New("failed to mutate vault") + } + + return SetVault(vault) +} + +type StoreIdentifier struct { + ModuleIdentifier + Version +} + +var storeIdentifierRe = regexp.MustCompile(`^(?[^@]*)@(?[^@]*)$`) + +func NewStoreIdentifier(identifier string) StoreIdentifier { + parts := storeIdentifierRe.FindStringSubmatch(identifier) + return StoreIdentifier{ + ModuleIdentifier: ModuleIdentifier(parts[1]), + Version: Version(parts[2]), + } +} + +func (si *StoreIdentifier) toString() string { + return string(si.ModuleIdentifier) + "@" + string(si.Version) +} + +func (si *StoreIdentifier) toPath() string { + return filepath.Join(storeFolder, string(si.ModuleIdentifier), string(si.Version)) +} diff --git a/paths/paths.go b/paths/paths.go new file mode 100644 index 0000000000..dc41b73dfc --- /dev/null +++ b/paths/paths.go @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package paths + +import ( + "os" + "path/filepath" +) + +var ( + ConfigPath string +) + +func GetDefaultSpotifyDataPath() string { + path, _ := GetPlatformSpotifyDataPath() + return path +} + +func GetDefaultSpotifyExecPath(spotifyDataPath string) string { + return GetPlatformSpotifyExecPath(spotifyDataPath) +} + +func GetDefaultSpotifyConfigPath() string { + path, _ := GetPlatformSpotifyConfigPath() + return path +} + +func GetSpotifyAppsPath(spotifyPath string) string { + return filepath.Join(spotifyPath, "Apps") +} + +func init() { + exe, err := os.Executable() + if err != nil { + panic(err) + } + realExe, err := filepath.EvalSymlinks(exe) + if err != nil { + panic(err) + } + ConfigPath = filepath.Dir(filepath.Dir(realExe)) +} diff --git a/paths/paths_darwin.go b/paths/paths_darwin.go new file mode 100644 index 0000000000..9b9101e6c0 --- /dev/null +++ b/paths/paths_darwin.go @@ -0,0 +1,29 @@ +//go:build darwin + +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package paths + +import ( + "os" + "path/filepath" +) + +func GetPlatformSpotifyDataPath() (string, error) { + return "/Applications/Spotify.app/Contents/Resources", nil +} + +func GetPlatformSpotifyExecPath(spotifyPath string) string { + return filepath.Join(spotifyPath, "Spotify") +} + +func GetPlatformSpotifyConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library/Application Support/Spotify"), nil +} diff --git a/paths/paths_linux.go b/paths/paths_linux.go new file mode 100644 index 0000000000..f42b746499 --- /dev/null +++ b/paths/paths_linux.go @@ -0,0 +1,65 @@ +//go:build linux + +/* + * Copyright (C) 2024 ririxi, Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package paths + +import ( + "os" + "path/filepath" + + e "spicetify/errors" + + "github.com/adrg/xdg" +) + +func isSpotifyInstallation(path string) bool { + // ? Do we need both checks? + _, err1 := os.Stat(filepath.Join(path, "Apps")) + _, err2 := os.Stat(filepath.Join(path, "spotify")) + return err1 == nil && err2 == nil +} + +func GetPlatformSpotifyDataPath() (string, error) { + paths := []string{ + "/opt/spotify/", + "/opt/spotify/spotify-client/", + "/usr/share/spotify/", + "/usr/libexec/spotify/", + "/var/lib/flatpak/app/com.spotify.Client/x86_64/stable/active/files/extra/share/spotify/", + } + + homeRelativePaths := []string{ + ".local/share/flatpak/app/com.spotify.Client/x86_64/stable/active/files/extra/share/spotify/", + ".local/share/spotify-launcher/install/usr/share/spotify/", + } + + if home, err := os.UserHomeDir(); err != nil { + for _, path := range homeRelativePaths { + paths = append(paths, filepath.Join(home, path)) + } + } + + for _, path := range paths { + if isSpotifyInstallation(path) { + return path, nil + } + } + + return "", e.ErrPathNotFound +} + +func GetPlatformSpotifyExecPath(spotifyDataPath string) string { + return filepath.Join(spotifyDataPath, "spotify") +} + +func GetPlatformSpotifyConfigPath() (string, error) { + pref := filepath.Join(xdg.ConfigHome, "spotify") + if _, err := os.Stat(pref); err != nil { + return "", err + } + return pref, nil +} diff --git a/paths/paths_windows.go b/paths/paths_windows.go new file mode 100644 index 0000000000..2335fa4b47 --- /dev/null +++ b/paths/paths_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +/* + * Copyright (C) 2024 Delusoire + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package paths + +import ( + "path/filepath" + + "github.com/adrg/xdg" +) + +func GetPlatformSpotifyDataPath() (string, error) { + return filepath.Join(xdg.DataDirs[0], "Spotify"), nil +} + +func GetPlatformSpotifyExecPath(spotifyDataPath string) string { + return filepath.Join(spotifyDataPath, "spotify.exe") +} + +func GetPlatformSpotifyConfigPath() (string, error) { + return filepath.Join(xdg.ConfigHome, "Spotify"), nil +} diff --git a/spicetify.go b/spicetify.go deleted file mode 100644 index fe6bc981c7..0000000000 --- a/spicetify.go +++ /dev/null @@ -1,523 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "io" - "log" - "os" - "os/exec" - "path/filepath" - "runtime" - "slices" - "strings" - "sync" - - colorable "github.com/mattn/go-colorable" - "github.com/spicetify/spicetify-cli/src/cmd" - spotifystatus "github.com/spicetify/spicetify-cli/src/status/spotify" - "github.com/spicetify/spicetify-cli/src/utils" -) - -var ( - version string -) - -var ( - flags = []string{} - commands = []string{} - quiet = false - extensionFocus = false - appFocus = false - styleFocus = false - noRestart = false - liveRefresh = 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] == '-' && v != "-1" { - if v[1] != '-' && len(v) > 2 { - 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 "-c", "--config": - fmt.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": - fmt.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 - } - - utils.MigrateConfigFolder() - 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 - } - - cmd.InitPaths() - - // 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 "path": - 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": - 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 - } - - utils.PrintBold("spicetify v" + version) - if slices.Contains(commands, "upgrade") || slices.Contains(commands, "update") { - updateStatus := cmd.Update(version) - if updateStatus { - ex, err := os.Executable() - if err != nil { - ex = "spicetify" - } - - spotifyPath := filepath.Join(utils.FindAppPath(), "Apps") - 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) - } - return - } else { - cmd.CheckUpdate(version) - } - - // Chainable commands - for _, v := range commands { - switch v { - case "backup": - cmd.Backup(version) - - case "clear": - cmd.Clear() - - case "apply": - cmd.CheckStates() - cmd.InitSetting() - cmd.Apply(version) - restartSpotify() - - case "refresh": - cmd.CheckStates() - cmd.InitSetting() - if extensionFocus { - cmd.RefreshExtensions() - } else if appFocus { - cmd.RefreshApps() - } else { - cmd.RefreshTheme() - } - - case "restore": - cmd.Restore() - restartSpotify() - - case "enable-devtools": - cmd.SetDevTools() - cmd.EvalSpotifyRestart(true) - - case "restart": - cmd.EvalSpotifyRestart(false) - - case "auto": - cmd.Auto(version) - cmd.EvalSpotifyRestart(true) - - default: - utils.PrintError(`Command "` + v + `" not found.`) - utils.PrintInfo(`Run "spicetify -h" for list of valid commands.`) - os.Exit(1) - } - } -} - -func restartSpotify() { - if !noRestart { - cmd.EvalSpotifyRestart(false) - } -} - -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 app files. - -apply Apply customization. - -refresh Refresh the theme's CSS, JS, colors, and assets. - Use with flag "-e" to update extensions. - -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 start using. - -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 extensions, custom apps, and active theme) - - -restart Restart Spotify client. - -` + utils.Bold("NON-CHAINABLE COMMANDS") + ` -path Prints 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